From 4f1a3b5f9ad05aa7b08715d48909a2b06ee2fcb1 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Mon, 15 Apr 2024 18:35:31 +0200 Subject: Adding upstream version 3.0.43. Signed-off-by: Daniel Baumann --- .codecov.yml | 1 + .github/workflows/test.yaml | 54 + .gitignore | 52 + .readthedocs.yml | 19 + AUTHORS.rst | 11 + CHANGELOG | 2171 ++++++++++++++++ LICENSE | 27 + MANIFEST.in | 4 + PROJECTS.rst | 68 + README.rst | 146 ++ appveyor.yml | 45 + docs/Makefile | 177 ++ docs/conf.py | 313 +++ docs/images/auto-suggestion.png | Bin 0 -> 72983 bytes docs/images/bottom-toolbar.png | Bin 0 -> 77372 bytes docs/images/colored-prompt.png | Bin 0 -> 94994 bytes docs/images/colorful-completions.png | Bin 0 -> 95099 bytes docs/images/dialogs/button.png | Bin 0 -> 134890 bytes docs/images/dialogs/confirm.png | Bin 0 -> 135405 bytes docs/images/dialogs/inputbox.png | Bin 0 -> 140141 bytes docs/images/dialogs/messagebox.png | Bin 0 -> 139732 bytes docs/images/dialogs/styled.png | Bin 0 -> 144850 bytes docs/images/hello-world-prompt.png | Bin 0 -> 105790 bytes docs/images/html-completion.png | Bin 0 -> 92781 bytes docs/images/html-input.png | Bin 0 -> 116804 bytes docs/images/logo_400px.png | Bin 0 -> 56254 bytes docs/images/multiline-input.png | Bin 0 -> 85296 bytes docs/images/number-validator.png | Bin 0 -> 81683 bytes docs/images/progress-bars/apt-get.png | Bin 0 -> 5411 bytes .../progress-bars/colored-title-and-label.png | Bin 0 -> 8575 bytes docs/images/progress-bars/custom-key-bindings.png | Bin 0 -> 8191 bytes docs/images/progress-bars/simple-progress-bar.png | Bin 0 -> 13218 bytes docs/images/progress-bars/two-tasks.png | Bin 0 -> 9723 bytes docs/images/ptpython-2.png | Bin 0 -> 133464 bytes docs/images/ptpython-history-help.png | Bin 0 -> 103337 bytes docs/images/ptpython-menu.png | Bin 0 -> 60225 bytes docs/images/ptpython.png | Bin 0 -> 28700 bytes docs/images/pymux.png | Bin 0 -> 205809 bytes docs/images/pyvim.png | Bin 0 -> 150054 bytes docs/images/repl/sqlite-1.png | Bin 0 -> 125880 bytes docs/images/repl/sqlite-2.png | Bin 0 -> 171192 bytes docs/images/repl/sqlite-3.png | Bin 0 -> 126517 bytes docs/images/repl/sqlite-4.png | Bin 0 -> 190998 bytes docs/images/repl/sqlite-5.png | Bin 0 -> 182631 bytes docs/images/repl/sqlite-6.png | Bin 0 -> 200352 bytes docs/images/rprompt.png | Bin 0 -> 79787 bytes docs/index.rst | 89 + docs/make.bat | 242 ++ docs/pages/advanced_topics/architecture.rst | 97 + docs/pages/advanced_topics/asyncio.rst | 30 + docs/pages/advanced_topics/filters.rst | 169 ++ docs/pages/advanced_topics/index.rst | 18 + docs/pages/advanced_topics/input_hooks.rst | 41 + docs/pages/advanced_topics/key_bindings.rst | 388 +++ docs/pages/advanced_topics/rendering_flow.rst | 86 + docs/pages/advanced_topics/rendering_pipeline.rst | 157 ++ docs/pages/advanced_topics/styling.rst | 320 +++ docs/pages/advanced_topics/unit_testing.rst | 125 + docs/pages/asking_for_input.rst | 1034 ++++++++ docs/pages/dialogs.rst | 270 ++ docs/pages/full_screen_apps.rst | 422 +++ docs/pages/gallery.rst | 32 + docs/pages/getting_started.rst | 84 + docs/pages/printing_text.rst | 274 ++ docs/pages/progress_bars.rst | 248 ++ docs/pages/reference.rst | 393 +++ docs/pages/related_projects.rst | 11 + docs/pages/tutorials/index.rst | 10 + docs/pages/tutorials/repl.rst | 341 +++ docs/pages/upgrading/2.0.rst | 221 ++ docs/pages/upgrading/3.0.rst | 118 + docs/pages/upgrading/index.rst | 11 + docs/requirements.txt | 5 + examples/dialogs/button_dialog.py | 19 + examples/dialogs/checkbox_dialog.py | 36 + examples/dialogs/input_dialog.py | 17 + examples/dialogs/messagebox.py | 16 + examples/dialogs/password_dialog.py | 19 + examples/dialogs/progress_dialog.py | 47 + examples/dialogs/radio_dialog.py | 39 + examples/dialogs/styled_messagebox.py | 37 + examples/dialogs/yes_no_dialog.py | 17 + examples/full-screen/ansi-art-and-textarea.py | 82 + examples/full-screen/buttons.py | 91 + examples/full-screen/calculator.py | 95 + examples/full-screen/dummy-app.py | 8 + examples/full-screen/full-screen-demo.py | 225 ++ examples/full-screen/hello-world.py | 43 + examples/full-screen/no-layout.py | 7 + examples/full-screen/pager.py | 111 + .../full-screen/scrollable-panes/simple-example.py | 45 + .../scrollable-panes/with-completion-menu.py | 120 + examples/full-screen/simple-demos/alignment.py | 60 + .../full-screen/simple-demos/autocompletion.py | 100 + examples/full-screen/simple-demos/colorcolumn.py | 63 + .../simple-demos/cursorcolumn-cursorline.py | 59 + .../full-screen/simple-demos/float-transparency.py | 87 + examples/full-screen/simple-demos/floats.py | 116 + examples/full-screen/simple-demos/focus.py | 98 + .../full-screen/simple-demos/horizontal-align.py | 208 ++ .../full-screen/simple-demos/horizontal-split.py | 44 + examples/full-screen/simple-demos/line-prefixes.py | 110 + examples/full-screen/simple-demos/margins.py | 71 + .../full-screen/simple-demos/vertical-align.py | 167 ++ .../full-screen/simple-demos/vertical-split.py | 44 + examples/full-screen/split-screen.py | 156 ++ examples/full-screen/text-editor.py | 383 +++ examples/gevent-get-input.py | 24 + examples/print-text/ansi-colors.py | 100 + examples/print-text/ansi.py | 50 + examples/print-text/html.py | 53 + examples/print-text/named-colors.py | 29 + examples/print-text/print-formatted-text.py | 46 + examples/print-text/print-frame.py | 14 + .../print-text/prompt-toolkit-logo-ansi-art.py | 40 + examples/print-text/pygments-tokens.py | 44 + examples/print-text/true-color-demo.py | 35 + examples/progress-bar/a-lot-of-parallel-tasks.py | 65 + examples/progress-bar/colored-title-and-label.py | 22 + examples/progress-bar/custom-key-bindings.py | 51 + examples/progress-bar/many-parallel-tasks.py | 46 + examples/progress-bar/nested-progress-bars.py | 22 + examples/progress-bar/scrolling-task-name.py | 23 + examples/progress-bar/simple-progress-bar.py | 18 + examples/progress-bar/styled-1.py | 36 + examples/progress-bar/styled-2.py | 49 + examples/progress-bar/styled-apt-get-install.py | 38 + examples/progress-bar/styled-rainbow.py | 35 + examples/progress-bar/styled-tqdm-1.py | 40 + examples/progress-bar/styled-tqdm-2.py | 38 + examples/progress-bar/two-tasks.py | 39 + examples/progress-bar/unknown-length.py | 26 + examples/prompts/accept-default.py | 16 + examples/prompts/asyncio-prompt.py | 63 + .../autocomplete-with-control-space.py | 75 + .../autocompletion-like-readline.py | 58 + examples/prompts/auto-completion/autocompletion.py | 60 + .../colored-completions-with-formatted-text.py | 137 + .../prompts/auto-completion/colored-completions.py | 78 + .../auto-completion/combine-multiple-completers.py | 76 + .../auto-completion/fuzzy-custom-completer.py | 56 + .../auto-completion/fuzzy-word-completer.py | 59 + .../multi-column-autocompletion-with-meta.py | 50 + .../auto-completion/multi-column-autocompletion.py | 57 + .../auto-completion/nested-autocompletion.py | 22 + .../prompts/auto-completion/slow-completions.py | 103 + examples/prompts/auto-suggestion.py | 48 + examples/prompts/autocorrection.py | 44 + examples/prompts/bottom-toolbar.py | 80 + examples/prompts/clock-input.py | 25 + examples/prompts/colored-prompt.py | 81 + examples/prompts/confirmation-prompt.py | 9 + examples/prompts/cursor-shapes.py | 19 + examples/prompts/custom-key-binding.py | 77 + examples/prompts/custom-lexer.py | 29 + .../prompts/custom-vi-operator-and-text-object.py | 70 + examples/prompts/enforce-tty-input-output.py | 13 + examples/prompts/fancy-zsh-prompt.py | 79 + examples/prompts/finalterm-shell-integration.py | 43 + examples/prompts/get-input-vi-mode.py | 7 + examples/prompts/get-input-with-default.py | 12 + examples/prompts/get-input.py | 9 + examples/prompts/get-multiline-input.py | 29 + .../get-password-with-toggle-display-shortcut.py | 28 + examples/prompts/get-password.py | 6 + examples/prompts/history/persistent-history.py | 25 + examples/prompts/history/slow-history.py | 48 + examples/prompts/html-input.py | 18 + examples/prompts/input-validation.py | 35 + examples/prompts/inputhook.py | 83 + examples/prompts/mouse-support.py | 10 + examples/prompts/multiline-prompt.py | 11 + examples/prompts/no-wrapping.py | 6 + examples/prompts/operate-and-get-next.py | 18 + examples/prompts/patch-stdout.py | 41 + examples/prompts/placeholder-text.py | 13 + examples/prompts/regular-language.py | 108 + examples/prompts/rprompt.py | 53 + examples/prompts/swap-light-and-dark-colors.py | 78 + examples/prompts/switch-between-vi-emacs.py | 36 + examples/prompts/system-clipboard-integration.py | 17 + examples/prompts/system-prompt.py | 20 + examples/prompts/terminal-title.py | 10 + .../prompts/up-arrow-partial-string-matching.py | 41 + examples/ssh/asyncssh-server.py | 120 + examples/telnet/chat-app.py | 103 + examples/telnet/dialog.py | 34 + examples/telnet/hello-world.py | 39 + examples/telnet/toolbar.py | 44 + examples/tutorial/README.md | 1 + examples/tutorial/sqlite-cli.py | 184 ++ mypy.ini | 18 + pyproject.toml | 66 + setup.cfg | 40 + setup.py | 53 + src/prompt_toolkit/__init__.py | 51 + src/prompt_toolkit/application/__init__.py | 32 + src/prompt_toolkit/application/application.py | 1625 ++++++++++++ src/prompt_toolkit/application/current.py | 189 ++ src/prompt_toolkit/application/dummy.py | 55 + src/prompt_toolkit/application/run_in_terminal.py | 113 + src/prompt_toolkit/auto_suggest.py | 176 ++ src/prompt_toolkit/buffer.py | 2026 +++++++++++++++ src/prompt_toolkit/cache.py | 127 + src/prompt_toolkit/clipboard/__init__.py | 17 + src/prompt_toolkit/clipboard/base.py | 108 + src/prompt_toolkit/clipboard/in_memory.py | 44 + src/prompt_toolkit/clipboard/pyperclip.py | 42 + src/prompt_toolkit/completion/__init__.py | 43 + src/prompt_toolkit/completion/base.py | 451 ++++ src/prompt_toolkit/completion/deduplicate.py | 45 + src/prompt_toolkit/completion/filesystem.py | 118 + src/prompt_toolkit/completion/fuzzy_completer.py | 213 ++ src/prompt_toolkit/completion/nested.py | 108 + src/prompt_toolkit/completion/word_completer.py | 94 + src/prompt_toolkit/contrib/__init__.py | 0 src/prompt_toolkit/contrib/completers/__init__.py | 5 + src/prompt_toolkit/contrib/completers/system.py | 64 + .../contrib/regular_languages/__init__.py | 79 + .../contrib/regular_languages/compiler.py | 571 ++++ .../contrib/regular_languages/completion.py | 94 + .../contrib/regular_languages/lexer.py | 93 + .../contrib/regular_languages/regex_parser.py | 282 ++ .../contrib/regular_languages/validation.py | 59 + src/prompt_toolkit/contrib/ssh/__init__.py | 8 + src/prompt_toolkit/contrib/ssh/server.py | 177 ++ src/prompt_toolkit/contrib/telnet/__init__.py | 7 + src/prompt_toolkit/contrib/telnet/log.py | 12 + src/prompt_toolkit/contrib/telnet/protocol.py | 208 ++ src/prompt_toolkit/contrib/telnet/server.py | 427 +++ src/prompt_toolkit/cursor_shapes.py | 104 + src/prompt_toolkit/data_structures.py | 18 + src/prompt_toolkit/document.py | 1181 +++++++++ src/prompt_toolkit/enums.py | 19 + src/prompt_toolkit/eventloop/__init__.py | 31 + src/prompt_toolkit/eventloop/async_generator.py | 124 + src/prompt_toolkit/eventloop/inputhook.py | 190 ++ src/prompt_toolkit/eventloop/utils.py | 101 + src/prompt_toolkit/eventloop/win32.py | 72 + src/prompt_toolkit/filters/__init__.py | 70 + src/prompt_toolkit/filters/app.py | 418 +++ src/prompt_toolkit/filters/base.py | 255 ++ src/prompt_toolkit/filters/cli.py | 64 + src/prompt_toolkit/filters/utils.py | 41 + src/prompt_toolkit/formatted_text/__init__.py | 58 + src/prompt_toolkit/formatted_text/ansi.py | 299 +++ src/prompt_toolkit/formatted_text/base.py | 180 ++ src/prompt_toolkit/formatted_text/html.py | 145 ++ src/prompt_toolkit/formatted_text/pygments.py | 32 + src/prompt_toolkit/formatted_text/utils.py | 102 + src/prompt_toolkit/history.py | 302 +++ src/prompt_toolkit/input/__init__.py | 14 + src/prompt_toolkit/input/ansi_escape_sequences.py | 343 +++ src/prompt_toolkit/input/base.py | 152 ++ src/prompt_toolkit/input/defaults.py | 79 + src/prompt_toolkit/input/posix_pipe.py | 118 + src/prompt_toolkit/input/posix_utils.py | 97 + src/prompt_toolkit/input/typeahead.py | 77 + src/prompt_toolkit/input/vt100.py | 309 +++ src/prompt_toolkit/input/vt100_parser.py | 249 ++ src/prompt_toolkit/input/win32.py | 749 ++++++ src/prompt_toolkit/input/win32_pipe.py | 156 ++ src/prompt_toolkit/key_binding/__init__.py | 22 + .../key_binding/bindings/__init__.py | 0 .../key_binding/bindings/auto_suggest.py | 65 + src/prompt_toolkit/key_binding/bindings/basic.py | 255 ++ .../key_binding/bindings/completion.py | 205 ++ src/prompt_toolkit/key_binding/bindings/cpr.py | 30 + src/prompt_toolkit/key_binding/bindings/emacs.py | 557 ++++ src/prompt_toolkit/key_binding/bindings/focus.py | 26 + src/prompt_toolkit/key_binding/bindings/mouse.py | 348 +++ .../key_binding/bindings/named_commands.py | 690 +++++ .../key_binding/bindings/open_in_editor.py | 51 + .../key_binding/bindings/page_navigation.py | 84 + src/prompt_toolkit/key_binding/bindings/scroll.py | 189 ++ src/prompt_toolkit/key_binding/bindings/search.py | 95 + src/prompt_toolkit/key_binding/bindings/vi.py | 2224 ++++++++++++++++ src/prompt_toolkit/key_binding/defaults.py | 62 + src/prompt_toolkit/key_binding/digraphs.py | 1377 ++++++++++ src/prompt_toolkit/key_binding/emacs_state.py | 36 + src/prompt_toolkit/key_binding/key_bindings.py | 671 +++++ src/prompt_toolkit/key_binding/key_processor.py | 529 ++++ src/prompt_toolkit/key_binding/vi_state.py | 107 + src/prompt_toolkit/keys.py | 222 ++ src/prompt_toolkit/layout/__init__.py | 146 ++ src/prompt_toolkit/layout/containers.py | 2743 ++++++++++++++++++++ src/prompt_toolkit/layout/controls.py | 944 +++++++ src/prompt_toolkit/layout/dimension.py | 219 ++ src/prompt_toolkit/layout/dummy.py | 39 + src/prompt_toolkit/layout/layout.py | 411 +++ src/prompt_toolkit/layout/margins.py | 303 +++ src/prompt_toolkit/layout/menus.py | 751 ++++++ src/prompt_toolkit/layout/mouse_handlers.py | 56 + src/prompt_toolkit/layout/processors.py | 1013 ++++++++ src/prompt_toolkit/layout/screen.py | 329 +++ src/prompt_toolkit/layout/scrollable_pane.py | 494 ++++ src/prompt_toolkit/layout/utils.py | 82 + src/prompt_toolkit/lexers/__init__.py | 20 + src/prompt_toolkit/lexers/base.py | 84 + src/prompt_toolkit/lexers/pygments.py | 327 +++ src/prompt_toolkit/log.py | 12 + src/prompt_toolkit/mouse_events.py | 89 + src/prompt_toolkit/output/__init__.py | 15 + src/prompt_toolkit/output/base.py | 331 +++ src/prompt_toolkit/output/color_depth.py | 64 + src/prompt_toolkit/output/conemu.py | 65 + src/prompt_toolkit/output/defaults.py | 102 + src/prompt_toolkit/output/flush_stdout.py | 87 + src/prompt_toolkit/output/plain_text.py | 143 + src/prompt_toolkit/output/vt100.py | 747 ++++++ src/prompt_toolkit/output/win32.py | 683 +++++ src/prompt_toolkit/output/windows10.py | 128 + src/prompt_toolkit/patch_stdout.py | 296 +++ src/prompt_toolkit/py.typed | 0 src/prompt_toolkit/renderer.py | 813 ++++++ src/prompt_toolkit/search.py | 230 ++ src/prompt_toolkit/selection.py | 61 + src/prompt_toolkit/shortcuts/__init__.py | 46 + src/prompt_toolkit/shortcuts/dialogs.py | 330 +++ .../shortcuts/progress_bar/__init__.py | 33 + src/prompt_toolkit/shortcuts/progress_bar/base.py | 448 ++++ .../shortcuts/progress_bar/formatters.py | 429 +++ src/prompt_toolkit/shortcuts/prompt.py | 1504 +++++++++++ src/prompt_toolkit/shortcuts/utils.py | 239 ++ src/prompt_toolkit/styles/__init__.py | 66 + src/prompt_toolkit/styles/base.py | 183 ++ src/prompt_toolkit/styles/defaults.py | 235 ++ src/prompt_toolkit/styles/named_colors.py | 161 ++ src/prompt_toolkit/styles/pygments.py | 69 + src/prompt_toolkit/styles/style.py | 400 +++ src/prompt_toolkit/styles/style_transformation.py | 373 +++ src/prompt_toolkit/token.py | 10 + src/prompt_toolkit/utils.py | 327 +++ src/prompt_toolkit/validation.py | 195 ++ src/prompt_toolkit/widgets/__init__.py | 62 + src/prompt_toolkit/widgets/base.py | 981 +++++++ src/prompt_toolkit/widgets/dialogs.py | 107 + src/prompt_toolkit/widgets/menus.py | 374 +++ src/prompt_toolkit/widgets/toolbars.py | 374 +++ src/prompt_toolkit/win32_types.py | 229 ++ tests/test_async_generator.py | 28 + tests/test_buffer.py | 112 + tests/test_cli.py | 941 +++++++ tests/test_completion.py | 469 ++++ tests/test_document.py | 69 + tests/test_filter.py | 131 + tests/test_formatted_text.py | 286 ++ tests/test_history.py | 103 + tests/test_inputstream.py | 141 + tests/test_key_binding.py | 200 ++ tests/test_layout.py | 53 + tests/test_memory_leaks.py | 35 + tests/test_print_formatted_text.py | 92 + tests/test_regular_languages.py | 102 + tests/test_shortcuts.py | 68 + tests/test_style.py | 276 ++ tests/test_style_transformation.py | 51 + tests/test_utils.py | 78 + tests/test_vt100_output.py | 20 + tests/test_widgets.py | 20 + tests/test_yank_nth_arg.py | 86 + tools/debug_input_cross_platform.py | 31 + tools/debug_vt100_input.py | 32 + tox.ini | 13 + 364 files changed, 59334 insertions(+) create mode 100644 .codecov.yml create mode 100644 .github/workflows/test.yaml create mode 100644 .gitignore create mode 100644 .readthedocs.yml create mode 100644 AUTHORS.rst create mode 100644 CHANGELOG create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 PROJECTS.rst create mode 100644 README.rst create mode 100644 appveyor.yml create mode 100644 docs/Makefile create mode 100644 docs/conf.py create mode 100644 docs/images/auto-suggestion.png create mode 100644 docs/images/bottom-toolbar.png create mode 100644 docs/images/colored-prompt.png create mode 100644 docs/images/colorful-completions.png create mode 100644 docs/images/dialogs/button.png create mode 100644 docs/images/dialogs/confirm.png create mode 100644 docs/images/dialogs/inputbox.png create mode 100644 docs/images/dialogs/messagebox.png create mode 100644 docs/images/dialogs/styled.png create mode 100644 docs/images/hello-world-prompt.png create mode 100644 docs/images/html-completion.png create mode 100644 docs/images/html-input.png create mode 100755 docs/images/logo_400px.png create mode 100644 docs/images/multiline-input.png create mode 100644 docs/images/number-validator.png create mode 100755 docs/images/progress-bars/apt-get.png create mode 100755 docs/images/progress-bars/colored-title-and-label.png create mode 100755 docs/images/progress-bars/custom-key-bindings.png create mode 100755 docs/images/progress-bars/simple-progress-bar.png create mode 100755 docs/images/progress-bars/two-tasks.png create mode 100644 docs/images/ptpython-2.png create mode 100644 docs/images/ptpython-history-help.png create mode 100644 docs/images/ptpython-menu.png create mode 100644 docs/images/ptpython.png create mode 100644 docs/images/pymux.png create mode 100644 docs/images/pyvim.png create mode 100644 docs/images/repl/sqlite-1.png create mode 100644 docs/images/repl/sqlite-2.png create mode 100644 docs/images/repl/sqlite-3.png create mode 100644 docs/images/repl/sqlite-4.png create mode 100644 docs/images/repl/sqlite-5.png create mode 100644 docs/images/repl/sqlite-6.png create mode 100644 docs/images/rprompt.png create mode 100644 docs/index.rst create mode 100644 docs/make.bat create mode 100644 docs/pages/advanced_topics/architecture.rst create mode 100644 docs/pages/advanced_topics/asyncio.rst create mode 100644 docs/pages/advanced_topics/filters.rst create mode 100644 docs/pages/advanced_topics/index.rst create mode 100644 docs/pages/advanced_topics/input_hooks.rst create mode 100644 docs/pages/advanced_topics/key_bindings.rst create mode 100644 docs/pages/advanced_topics/rendering_flow.rst create mode 100644 docs/pages/advanced_topics/rendering_pipeline.rst create mode 100644 docs/pages/advanced_topics/styling.rst create mode 100644 docs/pages/advanced_topics/unit_testing.rst create mode 100644 docs/pages/asking_for_input.rst create mode 100644 docs/pages/dialogs.rst create mode 100644 docs/pages/full_screen_apps.rst create mode 100644 docs/pages/gallery.rst create mode 100644 docs/pages/getting_started.rst create mode 100644 docs/pages/printing_text.rst create mode 100644 docs/pages/progress_bars.rst create mode 100644 docs/pages/reference.rst create mode 100644 docs/pages/related_projects.rst create mode 100644 docs/pages/tutorials/index.rst create mode 100644 docs/pages/tutorials/repl.rst create mode 100644 docs/pages/upgrading/2.0.rst create mode 100644 docs/pages/upgrading/3.0.rst create mode 100644 docs/pages/upgrading/index.rst create mode 100644 docs/requirements.txt create mode 100755 examples/dialogs/button_dialog.py create mode 100755 examples/dialogs/checkbox_dialog.py create mode 100755 examples/dialogs/input_dialog.py create mode 100755 examples/dialogs/messagebox.py create mode 100755 examples/dialogs/password_dialog.py create mode 100755 examples/dialogs/progress_dialog.py create mode 100755 examples/dialogs/radio_dialog.py create mode 100755 examples/dialogs/styled_messagebox.py create mode 100755 examples/dialogs/yes_no_dialog.py create mode 100755 examples/full-screen/ansi-art-and-textarea.py create mode 100755 examples/full-screen/buttons.py create mode 100755 examples/full-screen/calculator.py create mode 100755 examples/full-screen/dummy-app.py create mode 100755 examples/full-screen/full-screen-demo.py create mode 100755 examples/full-screen/hello-world.py create mode 100644 examples/full-screen/no-layout.py create mode 100755 examples/full-screen/pager.py create mode 100644 examples/full-screen/scrollable-panes/simple-example.py create mode 100644 examples/full-screen/scrollable-panes/with-completion-menu.py create mode 100755 examples/full-screen/simple-demos/alignment.py create mode 100755 examples/full-screen/simple-demos/autocompletion.py create mode 100755 examples/full-screen/simple-demos/colorcolumn.py create mode 100755 examples/full-screen/simple-demos/cursorcolumn-cursorline.py create mode 100755 examples/full-screen/simple-demos/float-transparency.py create mode 100755 examples/full-screen/simple-demos/floats.py create mode 100755 examples/full-screen/simple-demos/focus.py create mode 100755 examples/full-screen/simple-demos/horizontal-align.py create mode 100755 examples/full-screen/simple-demos/horizontal-split.py create mode 100755 examples/full-screen/simple-demos/line-prefixes.py create mode 100755 examples/full-screen/simple-demos/margins.py create mode 100755 examples/full-screen/simple-demos/vertical-align.py create mode 100755 examples/full-screen/simple-demos/vertical-split.py create mode 100755 examples/full-screen/split-screen.py create mode 100755 examples/full-screen/text-editor.py create mode 100755 examples/gevent-get-input.py create mode 100755 examples/print-text/ansi-colors.py create mode 100755 examples/print-text/ansi.py create mode 100755 examples/print-text/html.py create mode 100755 examples/print-text/named-colors.py create mode 100755 examples/print-text/print-formatted-text.py create mode 100755 examples/print-text/print-frame.py create mode 100755 examples/print-text/prompt-toolkit-logo-ansi-art.py create mode 100755 examples/print-text/pygments-tokens.py create mode 100755 examples/print-text/true-color-demo.py create mode 100755 examples/progress-bar/a-lot-of-parallel-tasks.py create mode 100755 examples/progress-bar/colored-title-and-label.py create mode 100755 examples/progress-bar/custom-key-bindings.py create mode 100755 examples/progress-bar/many-parallel-tasks.py create mode 100755 examples/progress-bar/nested-progress-bars.py create mode 100755 examples/progress-bar/scrolling-task-name.py create mode 100755 examples/progress-bar/simple-progress-bar.py create mode 100755 examples/progress-bar/styled-1.py create mode 100755 examples/progress-bar/styled-2.py create mode 100755 examples/progress-bar/styled-apt-get-install.py create mode 100755 examples/progress-bar/styled-rainbow.py create mode 100755 examples/progress-bar/styled-tqdm-1.py create mode 100755 examples/progress-bar/styled-tqdm-2.py create mode 100755 examples/progress-bar/two-tasks.py create mode 100755 examples/progress-bar/unknown-length.py create mode 100644 examples/prompts/accept-default.py create mode 100755 examples/prompts/asyncio-prompt.py create mode 100755 examples/prompts/auto-completion/autocomplete-with-control-space.py create mode 100755 examples/prompts/auto-completion/autocompletion-like-readline.py create mode 100755 examples/prompts/auto-completion/autocompletion.py create mode 100755 examples/prompts/auto-completion/colored-completions-with-formatted-text.py create mode 100755 examples/prompts/auto-completion/colored-completions.py create mode 100755 examples/prompts/auto-completion/combine-multiple-completers.py create mode 100755 examples/prompts/auto-completion/fuzzy-custom-completer.py create mode 100755 examples/prompts/auto-completion/fuzzy-word-completer.py create mode 100755 examples/prompts/auto-completion/multi-column-autocompletion-with-meta.py create mode 100755 examples/prompts/auto-completion/multi-column-autocompletion.py create mode 100755 examples/prompts/auto-completion/nested-autocompletion.py create mode 100755 examples/prompts/auto-completion/slow-completions.py create mode 100755 examples/prompts/auto-suggestion.py create mode 100755 examples/prompts/autocorrection.py create mode 100755 examples/prompts/bottom-toolbar.py create mode 100755 examples/prompts/clock-input.py create mode 100755 examples/prompts/colored-prompt.py create mode 100755 examples/prompts/confirmation-prompt.py create mode 100755 examples/prompts/cursor-shapes.py create mode 100755 examples/prompts/custom-key-binding.py create mode 100755 examples/prompts/custom-lexer.py create mode 100755 examples/prompts/custom-vi-operator-and-text-object.py create mode 100755 examples/prompts/enforce-tty-input-output.py create mode 100755 examples/prompts/fancy-zsh-prompt.py create mode 100755 examples/prompts/finalterm-shell-integration.py create mode 100755 examples/prompts/get-input-vi-mode.py create mode 100755 examples/prompts/get-input-with-default.py create mode 100755 examples/prompts/get-input.py create mode 100755 examples/prompts/get-multiline-input.py create mode 100755 examples/prompts/get-password-with-toggle-display-shortcut.py create mode 100755 examples/prompts/get-password.py create mode 100755 examples/prompts/history/persistent-history.py create mode 100755 examples/prompts/history/slow-history.py create mode 100755 examples/prompts/html-input.py create mode 100755 examples/prompts/input-validation.py create mode 100755 examples/prompts/inputhook.py create mode 100755 examples/prompts/mouse-support.py create mode 100755 examples/prompts/multiline-prompt.py create mode 100755 examples/prompts/no-wrapping.py create mode 100755 examples/prompts/operate-and-get-next.py create mode 100755 examples/prompts/patch-stdout.py create mode 100755 examples/prompts/placeholder-text.py create mode 100755 examples/prompts/regular-language.py create mode 100755 examples/prompts/rprompt.py create mode 100755 examples/prompts/swap-light-and-dark-colors.py create mode 100755 examples/prompts/switch-between-vi-emacs.py create mode 100755 examples/prompts/system-clipboard-integration.py create mode 100755 examples/prompts/system-prompt.py create mode 100755 examples/prompts/terminal-title.py create mode 100755 examples/prompts/up-arrow-partial-string-matching.py create mode 100755 examples/ssh/asyncssh-server.py create mode 100755 examples/telnet/chat-app.py create mode 100755 examples/telnet/dialog.py create mode 100755 examples/telnet/hello-world.py create mode 100755 examples/telnet/toolbar.py create mode 100755 examples/tutorial/README.md create mode 100755 examples/tutorial/sqlite-cli.py create mode 100644 mypy.ini create mode 100644 pyproject.toml create mode 100644 setup.cfg create mode 100755 setup.py create mode 100644 src/prompt_toolkit/__init__.py create mode 100644 src/prompt_toolkit/application/__init__.py create mode 100644 src/prompt_toolkit/application/application.py create mode 100644 src/prompt_toolkit/application/current.py create mode 100644 src/prompt_toolkit/application/dummy.py create mode 100644 src/prompt_toolkit/application/run_in_terminal.py create mode 100644 src/prompt_toolkit/auto_suggest.py create mode 100644 src/prompt_toolkit/buffer.py create mode 100644 src/prompt_toolkit/cache.py create mode 100644 src/prompt_toolkit/clipboard/__init__.py create mode 100644 src/prompt_toolkit/clipboard/base.py create mode 100644 src/prompt_toolkit/clipboard/in_memory.py create mode 100644 src/prompt_toolkit/clipboard/pyperclip.py create mode 100644 src/prompt_toolkit/completion/__init__.py create mode 100644 src/prompt_toolkit/completion/base.py create mode 100644 src/prompt_toolkit/completion/deduplicate.py create mode 100644 src/prompt_toolkit/completion/filesystem.py create mode 100644 src/prompt_toolkit/completion/fuzzy_completer.py create mode 100644 src/prompt_toolkit/completion/nested.py create mode 100644 src/prompt_toolkit/completion/word_completer.py create mode 100644 src/prompt_toolkit/contrib/__init__.py create mode 100644 src/prompt_toolkit/contrib/completers/__init__.py create mode 100644 src/prompt_toolkit/contrib/completers/system.py create mode 100644 src/prompt_toolkit/contrib/regular_languages/__init__.py create mode 100644 src/prompt_toolkit/contrib/regular_languages/compiler.py create mode 100644 src/prompt_toolkit/contrib/regular_languages/completion.py create mode 100644 src/prompt_toolkit/contrib/regular_languages/lexer.py create mode 100644 src/prompt_toolkit/contrib/regular_languages/regex_parser.py create mode 100644 src/prompt_toolkit/contrib/regular_languages/validation.py create mode 100644 src/prompt_toolkit/contrib/ssh/__init__.py create mode 100644 src/prompt_toolkit/contrib/ssh/server.py create mode 100644 src/prompt_toolkit/contrib/telnet/__init__.py create mode 100644 src/prompt_toolkit/contrib/telnet/log.py create mode 100644 src/prompt_toolkit/contrib/telnet/protocol.py create mode 100644 src/prompt_toolkit/contrib/telnet/server.py create mode 100644 src/prompt_toolkit/cursor_shapes.py create mode 100644 src/prompt_toolkit/data_structures.py create mode 100644 src/prompt_toolkit/document.py create mode 100644 src/prompt_toolkit/enums.py create mode 100644 src/prompt_toolkit/eventloop/__init__.py create mode 100644 src/prompt_toolkit/eventloop/async_generator.py create mode 100644 src/prompt_toolkit/eventloop/inputhook.py create mode 100644 src/prompt_toolkit/eventloop/utils.py create mode 100644 src/prompt_toolkit/eventloop/win32.py create mode 100644 src/prompt_toolkit/filters/__init__.py create mode 100644 src/prompt_toolkit/filters/app.py create mode 100644 src/prompt_toolkit/filters/base.py create mode 100644 src/prompt_toolkit/filters/cli.py create mode 100644 src/prompt_toolkit/filters/utils.py create mode 100644 src/prompt_toolkit/formatted_text/__init__.py create mode 100644 src/prompt_toolkit/formatted_text/ansi.py create mode 100644 src/prompt_toolkit/formatted_text/base.py create mode 100644 src/prompt_toolkit/formatted_text/html.py create mode 100644 src/prompt_toolkit/formatted_text/pygments.py create mode 100644 src/prompt_toolkit/formatted_text/utils.py create mode 100644 src/prompt_toolkit/history.py create mode 100644 src/prompt_toolkit/input/__init__.py create mode 100644 src/prompt_toolkit/input/ansi_escape_sequences.py create mode 100644 src/prompt_toolkit/input/base.py create mode 100644 src/prompt_toolkit/input/defaults.py create mode 100644 src/prompt_toolkit/input/posix_pipe.py create mode 100644 src/prompt_toolkit/input/posix_utils.py create mode 100644 src/prompt_toolkit/input/typeahead.py create mode 100644 src/prompt_toolkit/input/vt100.py create mode 100644 src/prompt_toolkit/input/vt100_parser.py create mode 100644 src/prompt_toolkit/input/win32.py create mode 100644 src/prompt_toolkit/input/win32_pipe.py create mode 100644 src/prompt_toolkit/key_binding/__init__.py create mode 100644 src/prompt_toolkit/key_binding/bindings/__init__.py create mode 100644 src/prompt_toolkit/key_binding/bindings/auto_suggest.py create mode 100644 src/prompt_toolkit/key_binding/bindings/basic.py create mode 100644 src/prompt_toolkit/key_binding/bindings/completion.py create mode 100644 src/prompt_toolkit/key_binding/bindings/cpr.py create mode 100644 src/prompt_toolkit/key_binding/bindings/emacs.py create mode 100644 src/prompt_toolkit/key_binding/bindings/focus.py create mode 100644 src/prompt_toolkit/key_binding/bindings/mouse.py create mode 100644 src/prompt_toolkit/key_binding/bindings/named_commands.py create mode 100644 src/prompt_toolkit/key_binding/bindings/open_in_editor.py create mode 100644 src/prompt_toolkit/key_binding/bindings/page_navigation.py create mode 100644 src/prompt_toolkit/key_binding/bindings/scroll.py create mode 100644 src/prompt_toolkit/key_binding/bindings/search.py create mode 100644 src/prompt_toolkit/key_binding/bindings/vi.py create mode 100644 src/prompt_toolkit/key_binding/defaults.py create mode 100644 src/prompt_toolkit/key_binding/digraphs.py create mode 100644 src/prompt_toolkit/key_binding/emacs_state.py create mode 100644 src/prompt_toolkit/key_binding/key_bindings.py create mode 100644 src/prompt_toolkit/key_binding/key_processor.py create mode 100644 src/prompt_toolkit/key_binding/vi_state.py create mode 100644 src/prompt_toolkit/keys.py create mode 100644 src/prompt_toolkit/layout/__init__.py create mode 100644 src/prompt_toolkit/layout/containers.py create mode 100644 src/prompt_toolkit/layout/controls.py create mode 100644 src/prompt_toolkit/layout/dimension.py create mode 100644 src/prompt_toolkit/layout/dummy.py create mode 100644 src/prompt_toolkit/layout/layout.py create mode 100644 src/prompt_toolkit/layout/margins.py create mode 100644 src/prompt_toolkit/layout/menus.py create mode 100644 src/prompt_toolkit/layout/mouse_handlers.py create mode 100644 src/prompt_toolkit/layout/processors.py create mode 100644 src/prompt_toolkit/layout/screen.py create mode 100644 src/prompt_toolkit/layout/scrollable_pane.py create mode 100644 src/prompt_toolkit/layout/utils.py create mode 100644 src/prompt_toolkit/lexers/__init__.py create mode 100644 src/prompt_toolkit/lexers/base.py create mode 100644 src/prompt_toolkit/lexers/pygments.py create mode 100644 src/prompt_toolkit/log.py create mode 100644 src/prompt_toolkit/mouse_events.py create mode 100644 src/prompt_toolkit/output/__init__.py create mode 100644 src/prompt_toolkit/output/base.py create mode 100644 src/prompt_toolkit/output/color_depth.py create mode 100644 src/prompt_toolkit/output/conemu.py create mode 100644 src/prompt_toolkit/output/defaults.py create mode 100644 src/prompt_toolkit/output/flush_stdout.py create mode 100644 src/prompt_toolkit/output/plain_text.py create mode 100644 src/prompt_toolkit/output/vt100.py create mode 100644 src/prompt_toolkit/output/win32.py create mode 100644 src/prompt_toolkit/output/windows10.py create mode 100644 src/prompt_toolkit/patch_stdout.py create mode 100644 src/prompt_toolkit/py.typed create mode 100644 src/prompt_toolkit/renderer.py create mode 100644 src/prompt_toolkit/search.py create mode 100644 src/prompt_toolkit/selection.py create mode 100644 src/prompt_toolkit/shortcuts/__init__.py create mode 100644 src/prompt_toolkit/shortcuts/dialogs.py create mode 100644 src/prompt_toolkit/shortcuts/progress_bar/__init__.py create mode 100644 src/prompt_toolkit/shortcuts/progress_bar/base.py create mode 100644 src/prompt_toolkit/shortcuts/progress_bar/formatters.py create mode 100644 src/prompt_toolkit/shortcuts/prompt.py create mode 100644 src/prompt_toolkit/shortcuts/utils.py create mode 100644 src/prompt_toolkit/styles/__init__.py create mode 100644 src/prompt_toolkit/styles/base.py create mode 100644 src/prompt_toolkit/styles/defaults.py create mode 100644 src/prompt_toolkit/styles/named_colors.py create mode 100644 src/prompt_toolkit/styles/pygments.py create mode 100644 src/prompt_toolkit/styles/style.py create mode 100644 src/prompt_toolkit/styles/style_transformation.py create mode 100644 src/prompt_toolkit/token.py create mode 100644 src/prompt_toolkit/utils.py create mode 100644 src/prompt_toolkit/validation.py create mode 100644 src/prompt_toolkit/widgets/__init__.py create mode 100644 src/prompt_toolkit/widgets/base.py create mode 100644 src/prompt_toolkit/widgets/dialogs.py create mode 100644 src/prompt_toolkit/widgets/menus.py create mode 100644 src/prompt_toolkit/widgets/toolbars.py create mode 100644 src/prompt_toolkit/win32_types.py create mode 100644 tests/test_async_generator.py create mode 100644 tests/test_buffer.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_completion.py create mode 100644 tests/test_document.py create mode 100644 tests/test_filter.py create mode 100644 tests/test_formatted_text.py create mode 100644 tests/test_history.py create mode 100644 tests/test_inputstream.py create mode 100644 tests/test_key_binding.py create mode 100644 tests/test_layout.py create mode 100644 tests/test_memory_leaks.py create mode 100644 tests/test_print_formatted_text.py create mode 100644 tests/test_regular_languages.py create mode 100644 tests/test_shortcuts.py create mode 100644 tests/test_style.py create mode 100644 tests/test_style_transformation.py create mode 100644 tests/test_utils.py create mode 100644 tests/test_vt100_output.py create mode 100644 tests/test_widgets.py create mode 100644 tests/test_yank_nth_arg.py create mode 100755 tools/debug_input_cross_platform.py create mode 100755 tools/debug_vt100_input.py create mode 100644 tox.ini diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..db24720 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1 @@ +comment: off diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..eedbdd8 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,54 @@ +name: test + +on: + push: # any branch + pull_request: + branches: [master] + +env: + FORCE_COLOR: 1 + +jobs: + test-ubuntu: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + - name: Install Dependencies + run: | + sudo apt remove python3-pip + python -m pip install --upgrade pip + python -m pip install . ruff typos coverage codecov mypy pytest readme_renderer types-contextvars asyncssh + pip list + - name: Ruff + run: | + ruff . + ruff format --check . + typos . + - name: Tests + run: | + coverage run -m pytest + - if: "matrix.python-version != '3.7'" + name: Mypy + # Check whether the imports were sorted correctly. + # When this fails, please run ./tools/sort-imports.sh + run: | + mypy --strict src/prompt_toolkit --platform win32 + mypy --strict src/prompt_toolkit --platform linux + mypy --strict src/prompt_toolkit --platform darwin + - name: Validate README.md + # Ensure that the README renders correctly (required for uploading to PyPI). + run: | + python -m readme_renderer README.rst > /dev/null + - name: Run codecov + run: | + codecov diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..337ddba --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +*.py[cod] + +# C extensions +*.so + +# Packages +*.egg +*.egg-info +dist +build +eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg +lib +lib64 +__pycache__ + +# Python 3rd Party +Pipfile* + +# Installer logs +pip-log.txt + +# Unit test / coverage reports +.coverage +.tox +nosetests.xml +.pytest_cache + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# Generated documentation +docs/_build + +# pycharm metadata +.idea + +# vscode metadata +.vscode + +# virtualenvs +.venv* diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 0000000..cd40bf2 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,19 @@ +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +formats: + - pdf + - epub + +sphinx: + configuration: docs/conf.py + +python: + install: + - requirements: docs/requirements.txt + - method: pip + path: . diff --git a/AUTHORS.rst b/AUTHORS.rst new file mode 100644 index 0000000..f7c8f60 --- /dev/null +++ b/AUTHORS.rst @@ -0,0 +1,11 @@ +Authors +======= + +Creator +------- +Jonathan Slenders + +Contributors +------------ + +- Amjith Ramanujam diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 0000000..e34916b --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,2171 @@ +CHANGELOG +========= + +3.0.43: 2023-12-13 +------------------ + +Fixes: +- Fix regression on Pypy: Don't use `ctypes.pythonapi` to restore SIGINT if not + available. + + +3.0.42: 2023-12-12 +------------------ + +Fixes: +- Fix line wrapping in `patch_stdout` on Windows. +- Make `formatted_text.split_lines()` accept an iterable instead of lists only. +- Disable the IPython workaround (from 3.0.41) for IPython >= 8.18. +- Restore signal.SIGINT handler between prompts. + + +3.0.41: 2023-11-14 +------------------ + +Fixes: +- Fix regression regarding IPython input hook (%gui) integration. + +3.0.40: 2023-11-10 +------------------ + +Fixes: +- Improved Python 3.12 support (fixes event loop `DeprecationWarning`). + +New features: +- Vi key bindings: `control-t` and `control-d` for indent/unindent in insert + mode. +- Insert partial suggestion when `control+right` is pressed, similar to Fish. +- Use sphinx-nefertiti theme for the docs. + + +3.0.39: 2023-07-04 +------------------ + +Fixes: +- Fix `RuntimeError` when `__breakpointhook__` is called from another thread. +- Fix memory leak in filters usage. +- Ensure that key bindings are handled in the right context (when using + contextvars). + +New features: +- Accept `in_thread` keyword in `prompt_toolkit.shortcuts.prompt()`. +- Support the `NO_COLOR` environment variable. + + +3.0.38: 2023-02-28 +------------------ + +Fixes: +- Fix regression in filters. (Use of `WeakValueDictionary` caused filters to + not be cached). + +New features: +- Use 24-bit true color now by default on Windows 10/11. + + +3.0.37: 2023-02-21 +------------------ + +Bug fixes: +- Fix `currentThread()` deprecation warning. +- Fix memory leak in filters. +- Make VERSION tuple numeric. + +New features: +- Add `.run()` method in `TelnetServer`. (To be used instead of + `.start()/.stop()`. + +Breaking changes: +- Subclasses of `Filter` have to call `super()` in their `__init__`. +- Drop support for Python 3.6: + * This includes code cleanup for Python 3.6 compatibility. + * Use `get_running_loop()` instead of `get_event_loop()`. + * Use `asyncio.run()` instead of `asyncio.run_until_complete()`. + + +3.0.36: 2022-12-06 +------------------ + +Fixes: +- Another Python 3.6 fix for a bug that was introduced in 3.0.34. + + +3.0.35: 2022-12-06 +------------------ + +Fixes: +- Fix bug introduced in 3.0.34 for Python 3.6. Use asynccontextmanager + implementation from prompt_toolkit itself. + + +3.0.34: 2022-12-06 +------------------ + +Fixes: +- Improve completion performance in various places. +- Improve renderer performance. +- Handle `KeyboardInterrupt` when the stacktrace of an unhandled error is + displayed. +- Use correct event loop in `Application.create_background_task()`. +- Fix `show_cursor` attribute in `ScrollablePane`. + + +3.0.33: 2022-11-21 +------------------ + +Fixes: +- Improve termination of `Application`. Don't suppress `CancelledError`. This + fixes a race condition when an `Application` gets cancelled while we're + waiting for the background tasks to complete. +- Fixed typehint for `OneStyleAndTextTuple`. +- Small bugfix in `CombinedRegistry`. Fixed missing `@property`. + + +3.0.32: 2022-11-03 +------------------ + +Bug fixes: +- Use `DummyInput` by default in `create_input()` if `sys.stdin` does not have + a valid file descriptor. This fixes errors when `sys.stdin` is patched in + certain situations. +- Fix control-c key binding for `ProgressBar` when the progress bar was not + created from the main thread. The current code would try to kill the main + thread when control-c was pressed. + +New features: +- Accept a `cancel_callback` in `ProgressBar` to specify the cancellation + behavior for when `control-c` is pressed. +- Small performance improvement in the renderer. + + +3.0.31: 2022-09-02 +------------------ + +New features: +- Pass through `name` property in `TextArea` widget to `Buffer`. +- Added a `enable_cpr` parameter to `Vt100_Output`, `TelnetServer` and + `PromptToolkitSSHServer`, to completely disable CPR support instead of + automatically detecting it. + + +3.0.30: 2022-06-27 +------------------ + +New features: +- Allow zero-width-escape sequences in `print_formatted_text`. +- Add default value option for input dialog. +- Added `has_suggestion` filter. + +Fixes: +- Fix rendering of control-shift-6 (or control-^). Render as '^^' +- Always wrap lines in the Label widget by default. +- Fix enter key binding in system toolbar in Vi mode. +- Improved handling of stdout objects that don't have a 'buffer' attribute. For + instance, when using `renderer_print_formatted_text` in a Jupyter Notebook. + + +3.0.29: 2022-04-04 +------------------ + +New features: +- Accept 'handle_sigint' parameter in PromptSession. + +Fixes +- Fix 'variable referenced before assignment' error in vt100 mouse bindings. +- Pass `handle_sigint` from `Application.run` to `Application.run_async`. +- Fix detection of telnet client side changes. +- Fix `print_container` utility (handle `EOFError`). + +Breaking changes: +- The following are now context managers: + `create_pipe_input`, `PosixPipeInput` and `Win32PipeInput`. + + +3.0.28: 2022-02-11 +------------------ + +New features: +- Support format specifiers for HTML and ANSI formatted text. +- Accept defaults for checkbox and radio list, and their corresponding dialogs. + +Fixes: +- Fix resetting of cursor shape after the application terminates. + + +3.0.27: 2022-02-07 +------------------ + +New features: +- Support for cursor shapes. The cursor shape for prompts/applications can now + be configured, either as a fixed cursor shape, or in case of Vi input mode, + according to the current input mode. +- Handle "cursor forward" command in ANSI formatted text. This makes it + possible to render many kinds of generated ANSI art. +- Accept `align` attribute in `Label` widget. +- Added `PlainTextOutput`: an output implementation that doesn't render any + ANSI escape sequences. This will be used by default when redirecting stdout + to a file. +- Added `create_app_session_from_tty`: a context manager that enforces + input/output to go to the current TTY, even if stdin/stdout are attached to + pipes. +- Added `to_plain_text` utility for converting formatted text into plain text. + +Fixes: +- Don't automatically use `sys.stderr` for output when `sys.stdout` is not a + TTY, but `sys.stderr` is. The previous behavior was confusing, especially + when rendering formatted text to the output, and we expect it to follow + redirection. + +3.0.26: 2022-01-27 +------------------ + +Fixes: +- Fixes issue introduced in 3.0.25: Don't handle SIGINT on Windows. + + +3.0.25: 2022-01-27 +------------------ + +Fixes: +- Use `DummyOutput` when `sys.stdout` is `None` and `DummyInput` when + `sys.stdin` is `None`. This fixes an issue when the code runs on windows, + using pythonw.exe and still tries to interact with the terminal. +- Correctly reset `Application._is_running` flag in case of exceptions in some + situations. +- Handle SIGINT (when sent from another process) and allow binding it to a key + binding. For prompt sessions, the behavior is now identical to pressing + control-c. +- Increase the event loop `slow_duration_callback` by default to 0.5. This + prevents printing warnings if rendering takes too long on slow systems. + + +3.0.24: 2021-12-09 +------------------ + +Fixes: +- Prevent window content overflowing when using scrollbars. +- Handle `PermissionError` when trying to attach /dev/null in vt100 input. + + +3.0.23: 2021-11-26 +------------------ + +Fixes: +- Fix multiline bracketed paste on Windows + +New features: +- Add support for some CSI 27 modified variants of "Enter" for xterm in the + vt100 input parser. + + +3.0.22: 2021-11-04 +------------------ + +Fixes: +- Fix stopping of telnet server (capture cancellation exception). + + +3.0.21: 2021-10-21 +------------------ + +New features: +- Improved mouse support: + * Support for click-drag, which is useful for selecting text. + * Detect mouse movements when no button is pressed. +- Support for Python 3.10. + + +3.0.20: 2021-08-20 +------------------ + +New features: +- Add support for strikethrough text attributes. +- Set up custom breakpointhook while an application is running (if no other + breakpointhook was installed). This enhances the usage of PDB for debugging + applications. +- Strict type checking is now enabled. + +Fixes: +- Ensure that `print_formatted_text` is always printed above the running + application, like `patch_stdout`. (Before, `patch_stdout` was even completely + ignored in case of `print_formatted_text, so there was no proper way to use + it in a running application.) +- Fix handling of non-bmp unicode input on Windows. +- Set minimum Python version to 3.6.2 (Some 3.6.2 features were used). + + +3.0.19: 2021-06-17 +------------------ + +Fixes: +- Make the flush method of the vt100 output implementation re-entrant (fixes an + issue when using aiogevent). +- Fix off-by-one in `FormattedTextControl` mouse logic. +- Run `print_container` always in a thread (avoid interfering with possible + event loop). +- Make sphinx autodoc generation platform agnostic (don't import Windows stuff + when generating Sphinx docs). + + +3.0.18: 2021-03-22 +------------------ + +New features: +- Added `in_thread` parameter to `Application.run`. + This is useful for running an application in a background thread, while the + main thread blocks. This way, we are sure not to interfere with an event loop + in the current thread. (This simplifies some code in ptpython and fixes an + issue regarding leaking file descriptors due to not closing the event loop + that was created in this background thread.) + + +3.0.17: 2021-03-11 +------------------ + +New features: +- Accept `style` parameter in `print_container` utility. +- On Windows, handle Control-Delete. + +Fixes: +- Avoid leaking file descriptors in SSH server. + + +3.0.16: 2021-02-11 +------------------ + +New features: +- Added `ScrollablePane`: a scrollable layout container. + This allows applications to build a layout, larger than the terminal, with a + vertical scroll bar. The vertical scrolling will be done automatically when + certain widgets receive the focus. +- Added `DeduplicateCompleter and `ConditionalCompleter`. +- Added `deduplicate` argument to `merge_completers`. + + +3.0.15: 2021-02-10 +------------------ + +Fixes: +- Set stdout blocking when writing in vt100 output. Fixes an issue when uvloop + is used and big amounts of text are written. +- Guarantee height of at least 1 for both labels and text areas. +- In the `Window` rendering, take `dont_extend_width`/`dont_extend_height` into + account. This fixes issues where one window is enlarged unexpectedly because + it's bundled with another window in a `HSplit`/`VSplit`, but with different + width/height. +- Don't handle `SIGWINCH` in progress bar anymore. (The UI runs in another + thread, and we have terminal size polling now). +- Fix several thread safety issues and a race condition in the progress bar. +- Fix thread safety issues in `Application.invalidate()`. (Fixes a + `RuntimeError` in some situations when using progress bars.) +- Fix handling of mouse events on Windows if we have a Windows 10 console with + ANSI support. +- Disable `QUICK_EDIT_MODE` on Windows 10 when mouse support is requested. + + +3.0.14: 2021-01-24 +------------------ + +New features: +- Disable bell when `PROMPT_TOOLKIT_BELL=false` environment variable has been + set. + +Fixes: +- Improve cancellation of history loading. + + +3.0.13: 2021-01-21 +------------------ + +Fixes: +- Again, fixed the race condition in `ThreadedHistory`. Previous fix was not + correct. + + +3.0.12: 2021-01-21 +------------------ + +Fixes: +- Fixed a race condition in `ThreadedHistory` that happens when continuously + pasting input text (which would continuously repopulate the history). +- Move cursor key mode resetting (for vt100 terminals) to the renderer. (Mostly + cleanup). + + +3.0.11: 2021-01-20 +------------------ + +New features: +- Poll terminal size: better handle resize events when the application runs in + a thread other than the main thread (where handling SIGWINCH doesn't work) or + in the Windows console. + +Fixes: +- Fix bug in system toolbar. The execution of system commands was broken. +- A refactoring of patch_stdout that includes several fixes. + * We know look at the `AppSession` in order to see which application is + running, rather then looking at the event loop which is installed when + `StdoutProxy` is created. This way, `patch_stdout` will work when + prompt_toolkit applications with a different event loop run. + * Fix printing when no application/event loop is running. + * Fixed the `raw` argument of `PatchStdout`. +- A refactoring of the `ThreadedHistory`, which includes several fixes, in + particular a race condition (see issue #1158) that happened when editing + input while a big history was still being loaded in the background. + + +3.0.10: 2021-01-08 +------------------ + +New features: +- Improved `WordCompleter`: accept `display_dict`. Also accept formatted text + for both `display_dict` and `meta_dict`. +- Allow customization of button arrows. + +Fixes: +- Correctly recognize backtab on Windows. +- Show original display text in fuzzy completer if no filtering was done. + + +3.0.9: 2021-01-05 +----------------- + +New features: +- Handle c-tab for TERM=linux. + +Fixes: +- Improve rendering speed of `print_formatted_text`. (Don't render styling + attributes to output between fragments that have identical styling.) +- Gracefully handle `FileHistory` decoding errors. +- Prevent asyncio deprecation warnings. + + +3.0.8: 2020-10-12 +----------------- + +New features: +- Added `validator` parameter to `input_dialog`. + +Fixes: +- Cope with stdout not having a working `fileno`. +- Handle situation when /dev/null is piped into stdin, or when stdin is closed + somehow. +- Fix for telnet/ssh server: `isatty` method was not implemented. +- Display correct error when a tuple is passed into `to_formatted_text`. +- Pass along WORD parameter in `Document._is_word_before_cursor_complete`. + Fixes some key bindings. +- Expose `ProgressBarCounter` in shortcuts module. + + +3.0.7: 2020-08-29 +----------------- + +New features: +- New "placeholder" parameter added to `PromptSession`. + +Other changes: +- The "respond to CPR" logic has been moved from the `Input` to `Output` + classes (this does clean up some code). + +Fixes: +- Bugfix in shift-selection key bindings. +- Fix height calculation of `FormattedTextControl` when line wrapping is turned + on. +- Fixes for SSH server: + * Missing encoding property. + * Fix failure in "set_line_mode" call. + * Handle `BrokenPipeError`. + + +3.0.6: 2020-08-10 +----------------- + +New features: +- The SSH/Telnet adaptors have been refactored and improved in several ways. + See issues #876 and PR #1150 and #1184 on GitHub. + * Handle terminal types for both telnet and SSH sessions. + * Added pipe input abstraction. (base class for `PosixPipeInput` and + `Win32PipeInput`). + * The color depth logic has been refactored and moved to the `Output` + implementations. Added `get_default_color_depth` method to `Output` + objects. + * All line feet are now preceded by a carriage return in the telnet + connection stdout. +- Introduce `REPLACE_SINGLE` input mode for Vi key bindings. +- Improvements to the checkbox implementation: + * Hide the scrollbar for a single checkbox. + * Added a "checked" setter to the checkbox. +- Expose `KeyPressEvent` in key_binding/__init__.py (often used in type + annotations). +- The renderer has been optimized so that no trailing spaces are generated + (this improves copying in some terminals). + +Fixes: +- Ignore F21..F24 key bindings by default. +- Fix auto_suggest key bindings when suggestion text is empty. +- Bugfix in SIGWINCH handling. +- Handle bug in HSplit/VSplit when the number of children is zero. +- Bugfix in CPR handling in renderer. Proper cancellation of pending tasks. +- Ensure rprompt aligns with input. +- Use `sys.stdin.encoding` for decoding stdin stream. + + +3.0.5: 2020-03-26 +----------------- + +Fixes: +- Bugfix in mouse handling on Windows. + + +3.0.4: 2020-03-06 +----------------- + +New features: +- Added many more vt100 ANSI sequences and keys. +- Improved control/shift key support in Windows. +- No Mypy errors in prompt_toolkit anymore. +- Added `set_exception_handler` optional argument to `PromptSession.prompt()`. + +Fixes: +- Bugfix in invalidate code. `PromptSession` was invalidating the UI + continuously. +- Add uvloop support (was broken due to an issue in our `call_soon_threadsafe`). +- Forwarded `set_exception_handler` in `Application.run` to the `run_async` call. +- Bugfix in `NestedCompleter` when there is a leading space. + +Breaking changes: +- `ShiftControl` has been replaced with `ControlShift` and `s-c` with `c-s` in + key bindings. Aliases for backwards-compatibility have been added. + + +3.0.3: 2020-01-26 +----------------- + +New features: +- Improved support for "dumb" terminals. +- Added support for new keys (vt100 ANSI sequences): Alt + + home/end/page-up/page-down/insert. +- Better performance for the "regular languages compiler". Generate fewer and + better regular expressions. This should improve the start-up time for + applications using this feature. +- Better detection of default color depth. +- Improved the progress bar: + * Set "time left" to 0 when done or stopped. + * Added `ProgressBarCounter.stopped`. +- Accept callables for `scroll_offset`, `min_brightness` and `max_brightness`. +- Added `always_prefer_tty` parameters to `create_input()` and `create_output()`. +- Create a new event loop in `Application.run()` if `get_event_loop()` raises + `Runtimeerror`. + +Fixes: +- Correct cancellation of flush timers for input. (Fixes resource leak where + too many useless coroutines were created.) +- Improved the Win32 input event loop. This fixes a bug where the + prompt_toolkit application is stopped by something other than user input. (In + that case, the application would hang, waiting for input.) This also fixes a + `RuntimeError` in the progress bar code. +- Fixed `line-number.current` style. (was `current-line-number`.) +- Handle situation where stdout is no longer a tty (fix bug in `get_size`). +- Fix parsing of true color in ANSI strings. +- Ignore `invalidate()` if the application is not running. + + +3.0.2: 2019-11-30 +----------------- + +Fixes: +- Bugfix in the UI invalidation. Fixes an issue when the application runs again + on another event loop. + See: https://github.com/ipython/ipython/pull/11973 + + +3.0.1: 2019-11-28 +----------------- + +New features: +- Added `new_eventloop_with_inputhook` function. +- Set exception handler from within `Application.run_async`. +- Applied Black code style. + +Fixes: +- No longer expect a working event loop in the `History` classes. + (Fix for special situations when a `ThreadedHistory` is created before the + event loop has been set up.) +- Accept an empty prompt continuation. +- A few fixes to the `Buffer` tempfile code. + + +3.0.0: 2019-11-24 +----------------- + +New features: +- (almost) 100% type annotated. +- Native asyncio instead of custom event loops. +- Added shift-based text selection (use shift+arrows to start selecting text). + +Breaking changes: +- Python 2 support has been dropped. Minimal Python version is now 3.6, + although 3.7 is preferred (because of ContextVars). +- Native asyncio, so some async code becomes slightly different. +- The active `Application` became a contextvar. Which means that it should be + propagated correctly to the code that requires it. However, random other + threads or coroutines won't be able to know what the current application is. +- The dialog shortcuts API changed. All dialog functions now return an + `Application`. You still have to call either `run()` or `run_async` on the + `Application` object. +- The way inputhooks work is changed. +- `patch_stdout` now requires an `Application` as input. + + +2.0.9: 2019-02-19 +----------------- + +Bug fixes: +- Fixed `Application.run_system_command` on Windows. +- Fixed bug in ANSI text formatting: correctly handle 256/true color sequences. +- Fixed bug in WordCompleter. Provide completions when there's a space before + the cursor. + + +2.0.8: 2019-01-27 +----------------- + +Bug fixes: +- Fixes the issue where changes made to the buffer in the accept handler were + not reflected in the history. +- Fix in the application invalidate handler. This prevents a significant slow + down in some applications after some time (especially if there is a refresh + interval). +- Make `print_container` utility work if the input is not a pty. + +New features: +- Underline non breaking spaces instead of rendering as '&'. +- Added mouse support for radio list. +- Support completion styles for `READLINE_LIKE` display method. +- Accept formatted text in the display text of completions. +- Added a `FuzzyCompleter` and `FuzzyWordCompleter`. +- Improved error handling in Application (avoid displaying a meaningless + AssertionError in many cases). + + +2.0.7: 2018-10-30 +----------------- + +Bug fixes: +- Fixed assertion in PromptSession: the style_transformation check was wrong. +- Removed 'default' attribute in PromptSession. Only ask for it in the + `prompt()` method. This fixes the issue that passing `default` once, will + store it for all consequent calls in the `PromptSession`. +- Ensure that `__pt_formatted_text__` always returns a `FormattedText` + instance. This fixes an issue with `print_formatted_text`. + +New features: +- Improved handling of situations where stdin or stdout are not a terminal. + (Print warning instead of failing with an assertion.) +- Added `print_container` utility. +- Sound bell when attempting to edit read-only buffer. +- Handle page-down and page-up keys in RadioList. +- Accept any `collections.abc.Sequence` for HSplit/VSplit children (instead of + lists only). +- Improved Vi key bindings: return to navigation mode when Insert is pressed. + + +2.0.6: 2018-10-12 +----------------- + +Bug fixes: +- Don't use the predefined ANSI colors for colors that are defined as RGB. + (Terminals can assign different color schemes for ansi colors, and we don't + want use any of those for colors that are defined like #aabbcc for instance.) +- Fix in handling of CPRs when patch_stdout is used. + +Backwards incompatible changes: +- Change to the `Buffer` class. Reset the buffer unless the `accept_handler` + returns `True` (which means: "keep_text"). This doesn't affect applications + that use `PromptSession`. + +New features: +- Added `AdjustBrightnessStyleTransformation`. This is a simple style + transformation that improves the rendering on terminals with light or dark + background. +- Improved performance (string width caching and line height calculation). +- Improved `TextArea`: + * Exposed `focus_on_click`. + * Added attributes: `auto_suggest`, `complete_while_typing`, `history`, + `get_line_prefix`, `input_processors`. + * Made attributes writable: `lexer`, `completer`, `complete_while_typing`, + `accept_handler`, `read_only`, `wrap_lines`. + +2.0.5: 2018-09-30 +----------------- + +Bug fixes: +- Fix in `DynamicContainer`. Return correct result for `get_children`. This + fixes a bug related to focusing. +- Properly compute length of `start`, `end` and `sym_b` characters of + progress bar. +- CPR (cursor position request) fix. + +Backwards incompatible changes: +- Stop restoring `PromptSession` attributes when exiting prompt. + +New features: +- Added `get_line_prefix` attribute to window. This opens many + possibilities: + * Line wrapping (soft and hard) can insert whitespace in front + of the line, or insert some symbols in front. Like the Vim "breakindent" + option. + * Single line prompts also support line continuations now. + * Line continuations can have a variable width. +- For VI mode: implemented temporary normal mode (control-O in insert mode). +- Added style transformations API. Useful for swapping between light and + dark color schemes. Added `swap_light_and_dark_colors` parameter to + `prompt()` function. +- Added `format()` method to ANSI formatted text. +- Set cursor position for Button widgets. +- Added `pre_run` argument to `PromptSession.prompt()` method. + + +2.0.4: 2018-07-22 +----------------- + +Bug fixes: +- Fix render height for rendering full screen applications in Windows. +- Fix in `TextArea`. Set `accept_handler` to `None` if not given. +- Go to the beginning of the next line when enter is pressed in Vi navigation + mode, and the buffer doesn't have an accept handler. +- Fix the `default` argument of the `prompt` function when called multiple + times. +- Display decomposed multiwidth characters correctly. +- Accept `history` in `prompt()` function again. + +Backwards incompatible changes: +- Renamed `PipeInput` to `PosixPipeInput`. Added `Win32PipeInput` and + `create_input_pipe`. +- Pass `buffer` argument to the `accept_handler` of `TextArea`. + +New features: +- Added `accept_default` argument to `prompt()`. +- Make it easier to change the body/title of a Frame/Dialog. +- Added `DynamicContainer`. +- Added `merge_completers` for merging multiple completers together. +- Add vt100 data to key presses in Windows. +- Handle left/right key bindings in Vi block insert mode. + + +2.0.3: 2018-06-08 +----------------- + +Bug fixes: +- Fix in 'x' and 'X' Vi key bindings. Correctly handle line endings and args. +- Fixed off by one error in Vi line selection. +- Fixed bugs in Vi block selection. Correctly handle lines that the selection + doesn't cross. +- Python 2 bugfix. Handle str/unicode correctly. +- Handle option+left/right in iTerm. + + +2.0.2: 2018-06-03 +----------------- + +Bug fixes: +- Python 3.7 support: correctly handle StopIteration in asynchronous generator. +- Fixed off-by-one bug in Vi visual block mode. +- Bugfix in TabsProcessor: handle situations when the cursor is at the end of + the line. + + +2.0.1: 2018-06-02 +----------------- + +Version 2.0 includes a big refactoring of the internal architecture. This +includes the merge of the CommandLineInterface and the Application object, a +rewrite of how user controls are focused, a rewrite of how event loops work +and the removal of the buffers dictionary. This introduces many backwards +incompatible changes, but the result is a very nice and powerful architecture. + +Most architectural changes effect full screen applications. For applications +that use `prompt_toolkit.shortcuts` for simple prompts, there are fewer +incompatibilities. + +Changes: + +- No automatic translation from \r into \n during the input processing. These + are two different keys that can be handled independently. This is a big + backward-incompatibility, because the `Enter` key is `ControlM`, not + `ControlJ`. So, now that we stopped translating \r into \n, it could be that + custom key bindings for `Enter` don't work anymore. Make sure to bind + `Keys.Enter` instead of `Keys.ControlJ` for handling the `Enter` key. + +- The `CommandLineInterface` and the `Application` classes are merged. First, + `CommandLineInterface` contained all the I/O objects (like the input, output + and event loop), while the `Application` contained everything else. There was + no practical reason to keep this separation. (`CommandLineInterface` was + mostly a proxy to `Application`.) + + A consequence is that almost all code which used to receive a + `CommandLineInterface`, will now use an `Application`. Usually, where we + had an attribute `cli`, we'll now have an attribute `app`. + + Secondly, the `Application` object is no longer passed around. The `get_app` + function can be used at any time to acquire the active application. + + (For backwards-compatibility, we have aliases to the old names, whenever + possible.) + +- prompt_toolkit no longer depends on Pygments, but it can still use Pygments + for its color schemes and lexers. In many places we used Pygments "Tokens", + this has been replaced by the concept of class names, somewhat similar to + HTML and CSS. + + * `PygmentsStyle` and `PygmentsLexer` adaptors are available for + plugging in Pygments styles and lexers. + + * Wherever we had a list of `(Token, text)` tuples, we now have lists of + `(style_string, text)` tuples. The style string can contain both inline + styling as well as refer to a class from the style sheet. `PygmentsTokens` + is an adaptor that converts a list of Pygments tokens into a list of + `(style_string, text)` tuples. + +- Changes in the `Style` classes. + + * `style.from_dict` does not exist anymore. Instantiate the ``Style`` class + directory to create a new style. ``Style.from_dict`` can be used to create + a style from a dictionary, where the dictionary keys are a space separated + list of class names, and the values, style strings (like before). + + * `print_tokens` was renamed to `print_formatted_text`. + + * In many places in the layout, we accept a parameter named `style`. All the + styles from the layout hierarchy are combined to decide what style to be + used. + + * The ANSI color names were confusing and inconsistent with common naming + conventions. This has been fixed, but aliases for the original names were + kept. + +- The way focusing works is different. Before it was always a `Buffer` that + was focused, and because of that, any visible `BufferControl` that contained + this `Buffer` would be focused. Now, any user control can be focused. All + of this is handled in the `Application.layout` object. + +- The `buffers` dictionary (`CommandLineInterface.buffers`) does not exist + anymore. Further, `buffers` was a `BufferMapping` that keeps track of which + buffer has the focus. This significantly reduces the freedom for creating + complex applications. We wanted to move toward a layout that can be defined + as a (hierarchical) collection of user widgets. A user widget does not need + to have a `Buffer` underneath and any widget should be focusable. + + * `layout.Layout` was introduced to contain the root layout widget and keep + track of the focus. + +- The key bindings were refactored. It became much more flexible to combine + sets of key bindings. + + * `Registry` has been renamed to `KeyBindings`. + * The `add_binding` function has been renamed to simply `add`. + * Every `load_*` function returns one `KeyBindings` objects, instead of + populating an existing one, like before. + * `ConditionalKeyBindings` was added. This can be used to enable/disable + all the key bindings from a given `Registry`. + * A function named `merge_key_bindings` was added. This takes a list of + `KeyBindings` and merges them into one. + * `key_binding.defaults.load_key_bindings` was added to load all the key + bindings. + * `KeyBindingManager` has been removed completely. + * `input_processor` was renamed to `key_processor`. + + Further: + + * The `Key` class does not exist anymore. Every key is a string and it's + considered fine to use string literals in the key bindings. This is more + readable, but we still have run-time validation. The `Keys` enum still + exist (for backwards-compatibility, but also to have an overview of which + keys are supported.) + * 'enter' and 'tab' are key aliases for 'c-m' and 'c-i'. + +- User controls can define key bindings, which are active when the user control + is focused. + + * `UIControl` got a `get_key_bindings` (abstract) method. + +- Changes in the layout engine: + + * `LayoutDimension` was renamed to `Dimension`. + * `VSplit` and `HSplit` now take a `padding` argument. + * `VSplit` and `HSplit` now take an `align` argument. + (TOP/CENTER/BOTTOM/JUSTIFY) or (LEFT/CENTER/RIGHT/JUSTIFY). + * `Float` now takes `allow_cover_cursor` and `attach_to_window` arguments. + * `Window` got an `WindowAlign` argument. This can be used for the alignment + of the content. `TokenListControl` (renamed to `FormattedTextControl`) does + not have an alignment argument anymore. + * All container objects, like `Window`, got a `style` argument. The style for + parent containers propagate to child containers, but can be overridden. + This is in particular useful for setting a background color. + * `FillControl` does not exist anymore. Use the `style` and `char` arguments + of the `Window` class instead. + * `DummyControl` was added. + * The continuation function of `PromptMargin` now takes `line_number` and + `is_soft_wrap` as input. + +- Changes to `BufferControl`: + + * The `InputProcessor` class has been refactored. The `apply_transformation` + method should now takes a `TransformationInput` object as input. + + * The text `(reverse-i-search)` is now displayed through a processor. (See + the `shortcuts` module for an example of its usage.) + +- `widgets` and `dialogs` modules: + + * A small collection of widgets was added. These are more complex collections + of user controls that are ready to embed in a layout. A `shortcuts.dialogs` + module was added as a high level API for displaying input, confirmation and + message dialogs. + + * Every class that exposes a ``__pt_container__`` method (which is supposed + to return a ``Container`` instance) is considered a widget. The + ``to_container`` shortcut will call this method in situations where a + ``Container`` object is expected. This avoids inheritance from other + ``Container`` types, but also having to unpack the container object from + the widget, in case we would have used composition. + + * Warning: The API of the widgets module is not considered stable yet, and + can change is the future, if needed. + +- Changes to `Buffer`: + + * A `Buffer` no longer takes an `accept_action`. Both `AcceptAction` and + `AbortAction` have been removed. Instead it takes an `accept_handler`. + +- Changes regarding auto completion: + + * The left and right arrows now work in the multi-column auto completion + menu. + * By default, autocompletion is synchronous. The completer needs to be + wrapped in `ThreadedCompleter` in order to get asynchronous autocompletion. + * When the completer runs in a background thread, completions will be + displayed as soon as they are generated. This means that we don't have to + wait for all the completions to be generated, before displaying the first + one. The completion menus are updated as soon as new completions arrive. + +- Changes regarding input validation: + + * Added the `Validator.from_callable` class method for easy creation of + new validators. + +- Changes regarding the `History` classes: + + * The `History` base class has a different interface. This was needed for + asynchronous loading of the history. `ThreadedHistory` was added for this. + +- Changes related to `shortcuts.prompt`: + + * There is now a class `PromptSession` which also has a method `prompt`. Both + the class and the method take about the same arguments. This can be used to + create a session. Every `prompt` call of the same instance will reuse all + the arguments given to the class itself. + + The input history is always shared during the entire session. + + Of course, it's still possible to call the global `prompt` function. This + will create a new `PromptSession` every time when it's called. + + * The `prompt` function now takes a `key_bindings` argument instead of + `key_bindings_registry`. This should only contain the additional bindings. + (The default bindings are always included.) + +- Changes to the event loops: + + * The event loop API is now closer to how asyncio works. A prompt_toolkit + `Application` now has a `Future` object. Calling the `.run_async()` method + creates and returns that `Future`. An event loop has a `run_until_complete` + method that takes a future and runs the event loop until the Future is set. + + The idea is to be able to transition easily to asyncio when Python 2 + support can be dropped in the future. + + * `Application` still has a method `run()` that underneath still runs the + event loop until the `Future` is set and returns that result. + + * The asyncio adaptors (like the asyncio event loop integration) now require + Python 3.5. (We use the async/await syntax internally.) + + * The `Input` and `Output` classes have some changes. (Not really important.) + + * `Application.run_sub_applications` has been removed. The alternative is to + call `run_coroutine_in_terminal` which returns a `Future`. + +- Changes to the `filters` module: + + * The `Application` is no longer passed around, so both `CLIFilter` and + `SimpleFilter` were merged into `Filter`. `to_cli_filter` and + `to_simple_filter` became `to_filter`. + + * All filters have been turned into functions. For instance, `IsDone` + became `is_done` and `HasCompletions` became `has_completions`. + + This was done because almost all classes were called without any arguments + in the `__init__` causing additional braces everywhere. This means that + `HasCompletions()` has to be replaced by `has_completions` (without + parenthesis). + + The few filters that took arguments as input, became functions, but still + have to be called with the given arguments. + + For new filters, it is recommended to use the `@Condition` decorator, + rather then inheriting from `Filter`. + +- Other renames: + + * `IncrementalSearchDirection` was renamed to `SearchDirection`. + * The `use_alternate_screen` parameter has been renamed to `full_screen`. + * `Buffer.initial_document` was renamed to `Buffer.document`. + * `TokenListControl` has been renamed to `FormattedTextControl`. + * `Application.set_return_value` has been renamed to `Application.set_result`. + +- Other new features: + + * `DummyAutoSuggest` and `DynamicAutoSuggest` were added. + * `DummyClipboard` and `DynamicClipboard` were added. + * `DummyCompleter` and `DynamicCompleter` were added. + * `DummyHistory` and `DynamicHistory` was added. + + * `to_container` and `to_window` utilities were added. + + +1.0.9: 2016-11-07 +----------------- + +Fixes: +- Fixed a bug in the `cooked_mode` context manager. This caused a bug in + ptpython where executing `input()` would display ^M instead of accepting the + input. +- Handle race condition in eventloop/posix.py +- Updated ANSI color names for vt100. (High and low intensity colors were + swapped.) + +New features: +- Added yank-nth-arg and yank-last-arg readline commands + Emacs bindings. +- Allow searching in Vi selection mode. +- Made text objects of the Vi 'n' and 'N' search bindings. This adds for + instance the following bindings: cn, cN, dn, dN, yn, yN + +1.0.8: 2016-10-16 +----------------- + +Fixes: +- In 'shortcuts': complete_while_typing was a SimpleFilter, not a CLIFilter. +- Always reset color attributes after rendering. +- Handle bug in Windows when '$TERM' is not defined. +- Ignore errors when calling tcgetattr/tcsetattr. + (This handles the "Inappropriate ioctl for device" crash in some scenarios.) +- Fix for Windows. Correctly recognize all Chinese and Lithuanian characters. + +New features: +- Added shift+left/up/down/right keys. +- Small performance optimization in the renderer. +- Small optimization in the posix event loop. Don't call time.time() if we + don't have an inputhook. (Less syscalls.) +- Turned the _max_postpone_until argument of call_from_executor into a float. + (As returned by `time.time`.) This will do less system calls. It's + backwards-incompatible, but this is still a private API, used only by pymux.) +- Added Shift-I/A commands in Vi block selection mode for inserting text at the + beginning of each line of the block. +- Refactoring of the 'selectors' module for the posix event loop. (Reuse the + same selector object in one loop, don't recreate it for each select.) + + +1.0.7: 2016-08-21 +----------------- + +Fixes: +- Bugfix in completion. When calculating the common completion to be inserted, + the new completions were calculated wrong. +- On Windows, avoid extra vertical scrolling if the cursor is already on screen. + +New features: +- Support negative arguments for next/previous word ending/beginning. + + +1.0.6: 2016-08-15 +----------------- + +Fixes: +- Go to the start of the line in Vi navigation mode, when 'j' or 'k' have been + pressed to navigate to a new history entry. +- Don't crash when pasting text that contains \r\n characters. (This could + happen in iTerm2.) +- Python 2.6 compatibility fix. +- Allow pressing before each -ve argument. +- Better support for conversion from #ffffff values to ANSI colors in + Vt100_Output. + * Prefer colors with some saturation, instead of gray colors, if the given + color was not gray. + * Prefer a different foreground and background color if they were + originally not the same. (This avoids concealing text.) + +New features: +- Improved ANSI color support. + * If the $PROMPT_TOOLKIT_ANSI_COLORS_ONLY environment variable has been + set, use the 16 ANSI colors only. + * Take an `ansi_colors_only` parameter in `Vt100_Output` and + `shortcuts.create_output`. + + +1.0.5: 2016-08-04 +----------------- + +Fixes: +- Critical fix for running on Windows. The gevent work-around in the inputhook + caused 'An operation was attempted on something that is not a socket'. + + +1.0.4: 2016-08-03 +----------------- + +Fixes: +- Key binding fixes: + * Improved handling of repeat arguments in Emacs mode. Pressing sequences + like 'esc---123' do now work (like GNU Readline): + - repetition of the minus sign is ignored. + - No esc prefix is required for each digit. + * Fix in ControlX-ControlX binding. + * Fix in bracketed paste. + * Pressing Control-U at the start of the line now deletes the newline. + * Pressing Control-K at the end of the line, deletes the newline after + the cursor. + * Support negative argument for Control-K + * Fixed cash when left/right were pressed with a negative argument. (In + Emacs mode.) + * Fix in ControlUp/ControlDown key bindings. + * Distinguish backspace from Control-H. They are not the same. + * Delete in front of the cursor when a negative argument has been given + to backspace. + * Handle arrow keys correctly in emacs-term. +- Performance optimizations: + * Performance optimization in Registry. + * Several performance optimization in filters. + * Import asyncio inline (only if required). +- Use the best possible selector in the event loop. This fixes bugs in + situations where we have too many open file descriptors. +- Fix UI freeze when gevent monkey patch has been applied. +- Fix segmentation fault in Alpine Linux. (Regarding the use of ioctl.) +- Use the correct colors on Windows. (When the foreground/background colors + have been modified.) +- Display a better error message when running in Idle. +- Additional flags for vt100 inputs: disable flow control. +- Also patch stderr in CommandLineInterface.patch_stdout_context. + +New features: +- Allow users to enter Vi digraphs in reverse order. +- Improved autocompletion behavior. See IPython issue #9658. +- Added a 'clear' function in the shortcuts module. + +For future compatibility: +- `Keys.Enter` has been added. This is the key that should be bound for + handling the enter key. + + Right now, prompt_toolkit translates \r into \n during the handling of the + input; this is not correct and makes it impossible to distinguish between + ControlJ and ControlM. Some applications bind ControlJ for custom handling of + the enter key, because this equals \n. However, in a future version we will + stop replacing \r by \n and at that point, the enter key will be ControlM. + So better is to use `Keys.Enter`, which becomes an alias for whatever the + enter key translates into. + + +1.0.3: 2016-06-20 +----------------- + +Fixes: +- Bugfix for Python2 in readline-like completion. +- Bugfix in readline-like completion visualization. + +New features: +- Added `erase_when_done` parameter to the `Application` class. (This was + required for the bug fixes.) +- Added (experimental) `CommandLineInterface.run_application_generator` method. + (Also required for the bug fix.) + +1.0.2: 2016-06-16 +----------------- + +Fixes: +- Don't select the first completion when `complete_while_typing` is False. + (Restore the old behavior.) + + +1.0.1: 2016-06-15 +----------------- + +Fixes: +- Bugfix in GrammarValidator and SentenceValidator. +- Don't leave the alternate screen on resize events. +- Use errors=surrogateescape, in order to handle mouse events in some + terminals. +- Ignore key presses in _InterfaceEventLoopCallbacks.feed_key when the CLI is in the done state. +- Bugfix in get_common_complete_suffix. Don't return any suffix when there are + completions that change whatever is before the cursor. +- Bugfix for Win32/Python2: use unicode literals: This crashed arrow navigation + on Windows. +- Bugfix in InputProcessor: handling of more complex key bindings. +- Fix: don't apply completions, if there is only one completion which doesn't + have any effect. +- Fix: correctly handle prompts starting with a newline in + prompt_toolkit.shortcuts. +- Fix: thread safety in autocomplete code. +- Improve styling for matching brackets. (Allow individual styling for the + bracket under the cursor and the other.) +- Fix in ShowLeadingWhiteSpaceProcessor/ShowTrailingWhiteSpaceProcessor: take + output encoding into account. (The signature had to change a little for + this.) +- Bug fix in key bindings: only activate Emacs system/open-in-editor bindings + if editing_mode is emacs. +- Added write_binary parameter to Vt100_Output. This fixes a bug in some cases + where we expect it to write non-encoded strings. +- Fix key bindings for Vi mode registers. + +New features (**): +- Added shortcuts.confirm/create_confirm_application function. +- Emulate bracketed paste on Windows. (When the input stream contains multiple + key presses among which a newline and at least one other character, consider + this a paste event, and handle as bracketed paste on Unix. +- Added key handler for displaying completions, just like readline does. +- Implemented Vi guu,gUU,g~~ key bindings. +- Implemented Vi 'gJ' key binding. +- Implemented Vi ab,ib,aB,iB text objects. +- Support for ZeroWidthEscape tokens in prompt and token lists. Used to support + final shell integration. +- Fix: Make document.text/cursor_position/selection read-only. (Changing these + would break the caching causing bigger issues.) +- Using pytest for unit tests. +- Allow key bindings to have Keys.Any at any possible position. (Not just the + end.) This made it significantly easier to write the named register Vi + bindings, resulting in an approved start-up time.) +- Better feedback when entering multi-key key bindings in insert mode. (E.g. + when 'jj' would be mapped to escape.) +- Small improvement in key processor: allow key bindings to generate new key + presses. +- Handle ControlUp and ControlDown by default: move to the previous/next record + in the history. +- Accept 'char'/'get_char' parameters in FillControl. +- Added refresh_interval method to prompt() function. + +Performance improvements: +- Improve the performance of test_callable_args: this should significantly + increase the start-up time. +- Start-up time for creating the Vi bindings has been improved significantly. + +(**) Some small backwards-compatible features were allowed for this minor + release. After evaluating the impact/risk/work involved we concluded that + we could ship these in a minor release. + + +1.0.0: 2016-05-05 +----------------- + +Fixes: +- Adjust minimum completion menu width to match UIControl and Window class. +- Bugfix regarding weakref in InputProcessor. +- Fix for pypy3: bug in WeakValueDictionary. +- Correctly handle '0' key binding in Vi mode. +- Also load Vi bindings by default in Application if no registry has been given. +- Only go into selection mode if the current buffer is not empty. +- Close PipeInput after usage. +- Only use 16 colors in (Emacs) eterm-color. +- Bugfix in "xP Vi key binding. +- Bugfix in Vi { and } key binding. +- Fix: use correct token for Scrollbar in MultiColumnCompletionMenuControl. +- Handle negative values in translate_row_col_to_index. +- Handle decomposed unicode characters. +- Fixed Window.always_hide_cursor. (Parameter was ignored.) +- Fix in zz Vi key binding. (When render info is not available.) +- Fix in Document.get_cursor_up_position. (When an argument is given.) + +New features: +- Separated `load_mouse_bindings`. +- Refactoring/simplification of the key bindings: better use of filters and + CLI.editing_mode. +- Added DummyOutput class and a few unit tests that test the whole CLI. +- Use the bisect module in Document._line_start_indexes instead of a custom + binary search. This should improve the performance. +- Stay in the same column when doing multiple up/down movements. +- Visual improvements: + * Implemented cursorcolumn, cursorline and colorcolumn. + * Only reserve menu space when `complete_while_typing=True` or when there are + completions to be displayed. + * Support for chaining tokens for combined styles. SelectedText will now + reverse the colors from the highlighting by default. Style + `Token.SelectedText` to set a fixed foreground/background. + Also for SearchMatch, we now use combined tokens. + * Support for dark gray on Windows. + * Default token for SystemToolbar and SearchToolbar. + * Display selection also on empty lines. +- Emacs key bindings improved: + * Recognize + handle ControlDelete key. + * Implemented meta-* and control-backslash key bindings. +- Vi key bindings improved: + * Handle inclusive and linewise motions properly. + * Fix g_ motion off by one character, and don't work when cursor is in + the trailing whitespace part of line. + * Make a(/a)/i(/i)/... motions. Find enclosing brackets instead of the next + bracket. + * Update N% motion according to vim behaviors. + * Fix | motion off by one character. + * ge/gE motions go to end of previous word, not start. + * Added Vi 'gm' key binding. + * Implemented 'gq' key binding in Vi mode. (Reshape text.) + * Vi operator/text object separation for key bindings. + * Added 'ap' (auto-paragraph) text object. + * Implemented Vi digraphs. ControlK will now insert a digraph. + * Implemented vi tilde_operator. + * Support named registers. + * Vi < and > key bindings became operators. + * Text objects and motions are now separate bindings. + * Improved copy/paste in Vi mode. + +Backwards-incompatible changes: +- Don't reset the current buffer anymore by default in + CommandLineInterface.run(). Passing `reset_current_buffer=True` is now + required. +- Renamed MouseEventTypes to MouseEventType for consistency. The old name is + still valid, but deprecated. +- Refactoring of Callbacks. All events should now receive one argument, which + is the sender. (Further, Callback was renamed to Event.) This is mostly used + internally. +- Moved on_invalidate callback from CommandLineInterface to Application +- Renamed `PipeInput.send` to `PipeInput.send_text`. (Old deprecated name is + still kept as a valid alias.) +- Renamed SimpleLexer.default_token to SimpleLexer.token. (+ + backwards-compatibility.) +- Refactoring of the filters: `ViStateFilter` has been deprecated. (Should not + be used anymore.) Use the filters, as defined in prompt_toolkit.filters. +- `editing_mode` is now a property of `CommandLineInterface`. This is replacing + the `vi_mode` parameter in `KeyBindingManager`. +- The default accept_action for the default Buffer in Application now becomes + IGNORE. This is a much more sensible default. Pass RETURN_DOCUMENT to get + the previous behavior, +- Always expect an EventLoop instance in CommandLineInterface. Creating it in + __init__ caused a memory leak. + + +0.60: 2016-03-14 +---------------- + +Fixes: +- Fix in Document.paste. (The screen was not updated after an undo of a paste.) +- Don't use deprecated inspect.getargspec on Python 3. +- Fixed reading input on Windows when input was piped in stdin. +- Use correct file descriptors for input/output in run_system_command. +- Always correctly split prompt in shortcuts.prompt. (Even when multiline=False) +- Correctly align right prompt to the top when the left prompt consists of + multiple lines. +- Correctly use Token.Transparent as default token for a TokenListControl. +- Fix in syntax synchronization. (Better handle the case when no + synchronization point was found.) +- Send SIGTSTP to the whole process group. +- Correctly raise on_buffer_changed on all text changes. +- Fix in regular_languages.GrammarLexer. (Fixes bug in ptipython syntax + highlighting.) + +New features: +- Add support for additional readers to the Win32 event loop. +- Added on_render event. +- Carry the weight in layout dimensions to allow stretching. + + +0.59: 2016-02-27 +---------------- + +Fixes: +- Set correct default color on Windows. (Gray instead of high intensity gray.) +- Reverse colors on Windows when foreground/background color have not been + specified. +- Correct handling of mouse events for FillControl. +- Take margin into account when calculating Window height. (Fixes bug in + multiline prompt.) +- Handle division by zero in UIContent.get_height_for_text. + + +0.58: 2016-02-23 +---------------- + +Fixes: +- Correctly return result for mouse handler in TokenListControl. +- Bugfix in meta-backspace key binding. (Delete all whitespace before the + cursor, when there is only whitespace.) +- Bugfix in Vi gu, gU, g? and g~ key bindings (in selection mode). +- Correctly restore default console attributes on Windows. +- Disable bracketed paste support in ConEmu. (This was broken.) +- When an unknown exception is raised in `CommandLineInterface.run()`, don't + forget to redraw the CLI. + +New features: +- Many performance improvements and better caching. (Especially in the + `Document` class.) +- Support for continuation tokens in `shortcuts.prompt` and + `shortcuts.create_prompt_layout`. +- Added `shortcuts.print_tokens` function for printing colored output. +- Sound bell when nothing was deleted. +- Added escape sequences for F1-F5 keys on the Linux console. +- Improved support for the Linux console. (Switch back to 16 colors.) +- Added F13-F24 input codes for xterm. +- Created prompt_toolkit.token. A custom Token implementation, that is + compatible with Pygments.token. (This way, Pygments becomes an optional + dependency. For many use cases, nothing except the Token class from Pygments + was used, so it was a bit overkill to install Pygments for only that.) +- Refactoring of prompt_toolkit.styles. +- `Float` objects got a `hide_when_covering_content` option. +- Implementation of RPROMPT, like ZSH: Added `get_rprompt_tokens` to + `create_prompt_layout`. +- Some improvements to the default style. +- Also handle Ctrl-R and Ctrl-S in Vi mode when searching. +- Added TabsProcessor: a tool to visualize tabs instead of displaying ^I. +- Give a better error message when trying to run in git-bash. +- Support for ANSI color names in style dictionaries. + +- Big refactoring of the `Window` and `UIControl` classes. This should result + in huge performance improvements on big inputs. (While first, a document + could have 1,000 lines; now it can have about 100,000 lines on the same system.) + + The Window and UIControl have been rewritten very much. Rather than each time + rendering the whole user control, we now only have to render the visible part. + + Because of this, many pieces had to be rewritten: + - UIControls work differently. They return a `UIContent` instance that + consist of a collection of lines. + - All processors have been rewritten. (Their API changed as well, because + they process one line at a time.) + - Lexers work differently. `Lexer.lex_document` should now return a function + that returns the tokens for one line. PygmentsLexer has been optimized that + it becomes 'lazy', and it has optional syntax synchronization. That means, + that the lexer doesn't have to start the lexing at the beginning of the + document. (Which would be slow for big documents.) + +Backwards-incompatible changes: +- As mentioned above, the refactoring of `Window` and `UIControl` caused many + "internal" APIs to change. All custom `UIControl`, `Processor` and `Lexer` + classes have to be rewritten. However, for most applications this should not + be an issue. Especially, the `shortcuts.prompt` function is + backwards-compatible. +- `wrap_lines` became a property of `Window` instead of `BufferControl`. + + +0.57: 2016-01-04 +---------------- + +Fixes: +- Made `max_render_postpone_time` configurable. The current default was bad. + (We should probably always draw the UI once every cycle of the event loop.) + + +0.56: 2016-01-03 +---------------- + +Fixes: +- Fix in bracketed paste. It was not correctly enabled for each prompt. + + +0.55: 2016-01-03 +---------------- + +New features: +- Implemented bracketed paste mode. (This allows much faster pasting, as well + as pasting without going into paste mode. This makes sure that indentation in + ptpython for instance is kept correctly.) +- Added support for italic output and blink. (For terminals that support it.) +- Added get_horizontal_scroll, get_vertical_scroll and always_hide_cursor + parameters to Window. +- Refactoring of the posix event loop. Better scheduling of all tasks/FDs to + avoid starvation. (Everything should feel more responsive in high CPU + situations.) +- Added get_default_char function to TokenListControl. +- AppendAutoSuggestion now accepts a token parameter. +- Support for ansi color names in styles. +- Accept get_width/get_height parameters in Float. +- Added Output.write_raw and accept 'raw' parameter in + CommandLineInterface.stdout_proxy. +- Better caching of tokens in TokenListControl. +- Add mouse support to TokenListControl. +- Display "Window too small" when the window becomes too small. +- Added 'bell' function to Output. +- Accept weights in HSplit/VSplit. +- Added Registry.remove_binding method to dynamically remove key bindings. +- Added focus_on_click parameter to BufferControl. +- Introduced BufferMapping class as a wrapper around the buffers dictionary. + This one also contains the focus stack. +- Improved 'v' and 'V' key bindings. Allow switching between line and character + selection modes. +- Added layout.highlighters. A new, much faster way to do selection and search + highlighting. +- Make search_state dynamic for key bindings. +- Added 'sentence' option to WordCompleter. +- Cache Document.lines for better performance. +- Implementation of BLOCK selections. (Cut, copy, paste.) +- Accept a 'reserve_space_for_menu' parameter in the shortcuts. (This is an + integer.) +- Support for 24bit true color on vt100 terminals. +- Added CommandLineInterface.on_invalidate event. +- Added __version__ to __init__.py. + +Fixes: +- Always show cursor in the 'done' state. +- Allow HSplit to have zero children. +- Bugfix for handling of backslash on Windows with some non-us keyboards. + (Ptpython issue #28.) +- Never render characters outside the visible screen region. +- Fix in WordCompleter. When case insensitive and input contained uppercase. +- Highlight search match when the cursor is at any position on the match. (not + just the beginning.) + +Backwards-incompatible changes: +(Most changes will probably not have an impact on external applications.) +- Change in the `Style` API. This allows caching of Attrs in renderer and + faster rendering. (Style now has a get_attrs_for_token instead of a + get_token_to_attributes_dict method.) +- Removed DefaultStyle. Created PygmentsStyle.from_defaults class method instead. +- Removed AbortAction.IGNORE. This was ambiguous. +- Accept 'cli' parameter in 'walk' and 'find_window_for_buffer_name'. +- The focus stack is now stored in BufferMapping. +- ViStateFilter and KeyBindingManager now accept a get_vi_state callable + instead of vi_state itself. (This way a key bindings registry becomes + stateless.) +- HighlightSearchProcessor and HighlightSelectionProcessor became deprecated. + (Use highlighters instead.) + + +0.54: 2015-10-29 +---------------- + +New features: +- Allow CommandLineInterface to run in any thread. +- Hide cursor while rendering. +- Added add_reader/remove_reader methods to EventLoop. +- Support for 'reverse' style. +- Redraw more lazy, by using invalidate. +- Added show_cursor property to Screen. +- Center or right align text in TokenListControl also when it spans multiple + lines. + +Fixes: +- Bugfix in PathCompleter. (Expanduser issue.) +- Fix in signal handler. +- Use utf-8 encoding in Vt100_Output by default. +- Use correct default token in BufferControl. +- Fix in ControlL key binding. Use @handle to allow deactivation. + +Backwards-incompatible changes: +- Renamed create_default_layout to create_prompt_layout +- Renamed create_default_application to create_prompt_application +- Renamed Layout to Container. +- Renamed CommandLineInterfaces.request_redraw to invalidate. +- Changed the actual value of SEARCH_BUFFER, DEFAULT_BUFFER, SYSTEM_BUFFER and + DUMMY_BUFFER. +- Changed order of keyword arguments of the BufferControl class. "buffer_name" + now comes first. +- Removed old pt(i)python code. + +0.53: 2015-10-06 +---------------- + +New features: +- Handling of the insert key in Vi mode. +- Added 'zt' and 'zb' Vi key bindings. +- Added delete key binding for deleting selected text. +- Select word below cursor on double mouse click. +- Added `wrap_lines` option to TokenListControl. +- Added `KeyBindingManager.for_prompt`. + +Fixes: +- Fix in rendering output. +- Reset renderer correctly in run_in_terminal. +- Only reset buffer when using `AbortAction.RETRY`. +- Fix in handling of exit (Ctrl-D) key presses. +- Fix in `CompleteEvent`. Correctly set `completion_requested`. + +Backwards-incompatible changes: +- Renamed `ValidationError.index` to `ValidationError.cursor_position`. +- Renamed `shortcuts.get_input` to `shortcuts.prompt`. +- Return empty string instead of None in + `Document.current_char`/`char_before_cursor`. + + +0.52: 2015-09-24 +---------------- + +Fixes: +- Fix in auto suggestion: hide suggestion when accepting input. + + +0.51: 2015-09-24 +---------------- + +New features: +- Mouse support. (Scrolling and clicking for vt100 terminals. For Windows only + clicking.) Both the autocompletion menus and buffer controls respond to + scrolling and clicking. +- Added auto suggestions. (Like the fish shell.) +- Stdout proxy become thread safe. +- Linewrapping can now be disabled, instead we get horizontal scrolling. +- Line numbering can now be relative. Like the vi 'relativenumber' option. + +Fixes: +- Fixed excessive scrolling in Windows. +- Bugfix in search highlighting. +- Copy all words during repetition of Ctrl-W presses. +- The 'libs' folder has been removed. +- Fix in MultiColumnCompletionsMenu: don't create very big columns. + +Backwards-incompatible changes: +- Disable search by default in KeyBindingManager. +- Separated abort/exit key bindings. Disabled by default in KeyBindingManager. +- 'Ignore' became the default on_abort action in `Application`. +- 'Ignore' became the default accept_action in `Buffer`. +- The layout processors have been refactored. The API is changed. +- `SwitchableValidator` has been renamed to `ConditionalValidator`. +- `WindowRenderInfo` has several incompatible changes. +- Margins have been refactored completely. Now it's the window that has the + margin instead of `BufferControl`. Is is both much more performant and + flexible. + + +0.50: 2015-09-06 +---------------- + +Fix: +- Leaving of alternate screen on Windows. + + +0.49: 2015-09-06 +---------------- + +New features: +- Added MANIFEST.in +- Better support for multiline prompts in shortcuts. +- Added Document.set_document method. +- Added 'default' argument to `shortcuts.create_default_application`. +- Added `align_center` option for `TokenListControl`. +- Added optional key bindings for full page navigation. (Moved key bindings + from pyvim into prompt-toolkit.) +- Accepts default_char in BufferControl for filling the background. +- Added InFocusStack filter. + +Fixes: +- Small fix in TokenListControl: use the right Char for aligning. + +Backwards-incompatible changes: +- Removed deprecated 'tokens' attribute from GrammarLexer. + + +0.48: 2015-09-02 +---------------- + +New features: +- run_in_terminal now returns the result of the called function. +- Made history attribute of Buffer class public. +- Added support for sub CommandLineInterfaces. +- Accept optional vi_state parameter in KeyBindingManager. + +Fixes: +- Pop-up menu positioning. The menu was shown too often above instead of below + the cursor. +- Fix in Control-W key binding. When there is only whitespace before the + cursor, delete the whitespace. +- Rendering bug fix in open_in_editor: run editor using cli.run_in_terminal. +- Fix in renderer. Correctly reserve the vertical space as required by the + layout. +- Small fix in Margin ABC. +- Added __iter__ to History ABC. +- Small bugfix in CommandLineInterface: create correct eventloop when no + eventloop was given. +- Never schedule a second repaint operation when a previous was not yet + executed. + + +0.47: 2015-08-19 +---------------- + +New features: +- Added `prompt_toolkit.layout.utils.iter_token_lines`. +- Allow `None` values on the focus stack. +- Buffers can be readonly. Added `IsReadOnly` filter. +- `eager` behavior for key bindings. When a key binding is eager it will be + executed as soon as it's matched, even when there is another binding that + starts with this key sequence. +- Custom margins for BufferControl. + +Fixes: +- Don't trigger autocompletion on paste. +- Added `pre_run` parameter to CommandLineInterface. +- Correct invalidation of BeforeInput and AfterInput. +- Correctly handle transparency. (For floats.) +- Small change in the algorithm to determine Window dimensions: keep in the + bounds of the Window dimensions. + +Backwards-incompatible changes: +- There a now a `Lexer` abstract base class. Every lexer should be an instance + of that class, and Pygments lexers should be wrapped in a `PygmentsLexer` + class. `prompt_toolkit.shortcuts` still accepts Pygments lexers directly for + backwards-compatibility. +- BufferControl no longer has a `show_line_numbers` argument. Pass a + `NumberedMargin` instance instead. +- The `History` class became an abstract base class and only defines an + interface. The default history class is now called `InMemoryHistory`. + + +0.46: 2015-08-08 +---------------- + +New features: +- By default, in shortcuts, only show search highlighting when the search is + the current input buffer. +- Accept 'count' for all search operations. (For repetition.) +- `shortcuts.create_default_layout` accepts a `multiline` parameter. +- Show meta display text for completions also in multi-column mode. + +Fixes: +- Correct invalidation of DefaultPrompt when search direction changes. +- Correctly include/exclude current cursor position in search. +- More consistency in styles. +- Fix in ConditionalProcessor.has_focus. +- Python 2.6 compatibility fix. +- Show cursor at the correct position during reverse-i-search. +- Fixed stdout encoding bug for vt100 output. + +Backwards-incompatible changes: +- Use of `ConditionalContainer` everywhere. The `Window` class no longer + accepts a `filter` argument to decide about the visibility. Instead + wrapping inside a `ConditionalContainer` class is required. + + +0.45: 2015-07-30 +---------------- + +Fixes: +- Bug fix on OS X: correctly detect platform as not Windows. + + +0.44: 2015-07-30 +---------------- + +Fixes: +- Fixed bug in eventloops: handle timeout correctly, even when there is an eventhook. +- Bug fix in open-in-editor: set correct cursor position. + +New features: +- CompletionsMenu got a scroll_offset. +- Use 256 colors and ANSI sequences when ConEmu ANSI support has been detected. +- Added PyperclipClipboard for synchronization with the system clipboard. + and clipboard parameter in shortcut functions. +- Filter for enabling/disabling handling of Vi 'v' binding. + + +0.43: 2015-07-15 +---------------- + +Fixes: +- Windows bug fix. STD_INPUT_HANDLE should be c_ulong instead of HANDLE. + (This caused crashes on some systems.) + +New features: +- Added eventloop and patch_stdout parameters to get_input. +- Inputhook support added. +- Added ShowLeadingWhiteSpaceProcessor and ShowTrailingWhiteSpaceProcessor + processors. +- Accept Filter as multiline parameter in 'shortcuts'. +- MultiColumnCompletionsMenu + display_completions_in_columns parameter + in shortcuts. + +Backwards incompatible changes: +- Layout.width was renamed to preferred_width and now receives a + max_available_width parameter. + + +0.42: 2015-06-25 +---------------- + +Fixes: +- Support for Windows cmder and conemu consoles. +- Correct handling of unicode input and output on Windows. + +New features: +- Support terminal titles. +- Handle Control-Enter as Meta-Enter on Windows. +- Control-Z key binding for Windows. +- Implemented alternate screen buffer on Windows. +- Clipboard became an ABC and InMemoryClipboard default implementation. + + +0.41: 2015-06-20 +---------------- + +Fixes: +- Emacs Control-T key binding. +- Color fix for Windows consoles. + +New features: +- Allow both booleans and Filters in many places. +- `password` can be a Filter now. + + +0.40: 2015-06-15 +---------------- + +Fixes: +- Fix in output_screen_diff: reset correctly. +- Ignore flush errors in vt100_output. +- Implemented gg Vi key binding. +- Bug fix in the renderer when the style changes. + +New features: +- TokenListControl can now display the cursor somewhere. +- Added SwitchableValidator class. +- print_tokens function added. +- get_style argument for Application added. +- KeyBindingManager got an enable_all argument. + +Backwards incompatible changes: +- history_search is now a SimpleFilter instance. + + +0.39: 2015-06-04 +---------------- + +Fixes: +- Fixed layout.py example. +- Fixed eventloop for Python 64bit on Windows. +- Fix in history. +- Fix in key bindings. + + +0.38: 2015-05-31 +---------------- + +New features: +- Improved performance significantly for processing key bindings. + (Pasting text will be a lot faster.) +- Added 'M' Vi key binding. +- Added 'z-' and 'z+' and 'z-[Enter]' Vi keybindings. +- Correctly handle input and output encodings on Windows. + +Bug fixes: +- Fix bug when completion cursor position is outside range of current text. +- Don't crash Control-D is pressed while waiting for ENTER press (in run_system_command.) +- On Ctrl-Z, don't suspend on Windows, where we don't have SIGTSTP. +- Ignore result when open_in_editor received a nonzero return code. +- Bug fix in displaying of menu meta information. Don't show 'None'. + +Backwards incompatible changes: +- Refactoring of the I/O layer. Separation of the CommandLineInterface + and Application class. +- Renamed enable_system_prompt to enable_system_bindings. + +0.37: 2015-05-11 +---------------- + +New features: +- Handling of trailing input in contrib.regular_languages. + +Bug fixes: +- Default message in shortcuts.get_input. +- Windows compatibility for contrib.telnet. +- OS X bugfix in contrib.telnet. + +0.36: 2015-05-09 +---------------- + +New features: +- Added get_prompt_tokens parameter to create_default_layout. +- Show prompt in bold by default. + +Bug fixes: +- Correct cache invalidation of DefaultPrompt. +- Using text_type assertions in contrib.telnet. +- Removed contrib.shortcuts completely. (The .pyc files still + appeared incorrectly in the wheel.) + +0.35: 2015-05-07 +---------------- + +New features: +- WORD parameter for WordCompleter. +- DefaultPrompt.from_message constructor. +- Added reactive.py for simple integer data binding. +- Implemented scroll_offset and scroll_beyond_bottom for Window. +- Some performance improvements. + +Bug fixes: +- Handling of relative path in PathCompleter. +- unicode_literals for all examples. +- Visibility of bottom toolbar in create_default_layout shortcut. +- Correctly handle 'J' vi key binding. +- Fix in indent/unindent. +- Better Vi bindings in visual mode. + +Backwards incompatible changes: +- Moved prompt_toolkit.contrib.shortcuts to prompt_toolkit.shortcuts. +- Refactoring of contrib.telnet. + +0.34: 2015-04-26 +---------------- + +Bug fixes: +- Correct display of multi width characters in completion menu. + +Backwards incompatible changes: +- Renamed Buffer.add_to_history to Buffer.append_to_history. + +0.33: 2015-04-25 +---------------- + +Bug fixes: +- Crash fixed in SystemCompleter when some directories didn't exist. +- Made text/cursor_position in Document more atomic. +- Fixed Char.__ne__, improves performance. +- Better performance of the filter module. +- Refactoring of the filter module. +- Bugfix in BufferControl, caching was not done correctly. +- fixed 'zz' Vi key binding. + +New features: +- Do tilde expansion for system commands. +- Added ignore_case option for CommandLineInterface. + +Backwards incompatible changes: +- complete_while_typing parameter has been moved from CommandLineInterface to + Buffer. + + +0.32: 2015-04-22 +---------------- + +New features: +- Implemented repeat arg for '{' and '}' vi key binding. +- Added autocorrection example. +- first experimental telnet interface added. +- Added contrib.validators.SentenceValidator. +- Added Layout.walk generator to traverse the layout. +- Improved 'L' and 'H' Vi key bindings. +- Implemented Vi 'zz' key binding. +- ValidationToolbar got a show_position parameter. +- When only width or height are given for a float, the control is centered in + the parent. +- Added beforeKeyPress and afterKeyPress events. +- Added HighlightMatchingBracketProcessor. +- SearchToolbar got a vi_mode option to show '?' and '/' instead of 'I-search'. +- Implemented vi '*' binding. +- Added onBufferChanged event to CommandLineInterface. +- Many performance improvements: some caching and not rendering after every + single key stroke. +- Added ConditionalProcessor. +- Floating menus are now shown above the cursor, when below is not enough + space, but above is enough space. +- Improved vi 'G' key binding. +- WindowRenderInfo got a full_height_visible, top_visible, and a few other + attributes. +- PathCompleter got an expanduser option to do tilde expansion. + +Fixed: +- Always insert indentation when pressing enter. +- vertical_scroll should be an int instead of a float. +- Some bug fixes in renderer.Output. +- Pressing backspace in an empty search in Vi mode now goes back to navigation + mode. +- Bug fix in TokenListControl (Correctly calculate height for multiline + content.) +- Only apply HighlightMatchingBracketProcessor when editing buffer. +- Ensure that floating layouts never go out of bounds. +- Home/End now go to the start and end of the line. +- Fixed vi 'c' key binding. +- Redraw the whole output when the style changes. +- Don't trigger onTextInsert when working_index doesn't change. +- Searching now wraps around the start/end of buffer/history. +- Don't go to the start of the line when moving forward in history. + +Changes: +- Don't show directory/file/link in the meta information of PathCompleter anymore. +- Complete refactoring of the event loops. +- Refactoring of the Renderer and CommandLineInterface class. +- CommandLineInterface now accepts an optional Output argument. +- CommandLineInterface now accepts a use_alternate_screen parameter. +- Moved highlighting code for search/selection from BufferControl to processors. +- Completers are now always run asynchronously. +- Complete refactoring of the search. (Most responsibility move out of Buffer + class. CommandLineInterface now got a search_state attribute.) + +Backwards incompatible changes: +- get_input does now have a history attribute instead of history_filename. +- EOFError and KeyboardInterrupt is raised for abort and exit instead of custom + exceptions. +- CommandLineInterface does no longer have a property 'is_reading_input'. +- filters.AlwaysOn/AlwaysOff have been renamed to Always/Never. +- AcceptAction has been moved from CommandLineInterface to Buffer. Now every + buffer can define its own accept action. +- CommandLineInterface now expects an Eventloop instance in __init__. + + +0.31: 2015-01-30 +---------------- + +Fixed: +- Bug in float positioning +- Show completion menu only for the default_buffer in get_input. + +New features: +- PathCompleter got a get_paths parameter. +- PathCompleter sorts alphabetically. +- Added contrib.completers.SystemCompleter +- Completion got a get_display_meta parameter. + + +0.30: 2015-01-26 +---------------- + +Fixed: +- Backward compatibility with django_extensions. +- Usage of alternate screen in the renderer. + +New features: +- Vi '#' key binding. +- contrib.shortcuts.get_input got a get_bottom_toolbar_tokens argument. +- Separate key bindings for "open in editor." KeyBindingManager got a + enable_open_in_editor argument. + +0.28: 2015-01-25 +---------------- + +Fixed: +- syntax error in 0.27 + +0.27: 2015-01-25 +---------------- + +Backwards-incompatible changes: +- Complete refactoring of the layout system. (HSplit, VSplit, FloatContainer) + as well as a list of controls (TokenListControl, BufferControl) in order to + design much more complex layouts. +- ptpython code has been moved to a separate repository. + +New features: +- prompt_toolkit.contrib.shortcuts.get_input has been extended. + +Fixed: +- Behavior of Control+left/right/up/down. +- Backspace in incremental search. +- Hide completion menu correctly when the cursor position changes. + +0.26: 2015-01-08 +---------------- + +Backwards-incompatible changes: +- Refactoring of the key input system. (The registry which contains the key + bindings, the focus stack, key binding manager.) Overall much better API. +- Renamed `Line` to `Buffer`. + +New features: +- Added filters as a way of disabling/enabling parts of the runtime according + to certain conditions. +- Global clipboard, shared between all buffers. +- Added (experimental) "merge history" feature to ptpython. +- Added 'C-x r k' and 'C-x r y' emacs key bindings for cut and paste. +- Added g_, ge and gE vi key bindings. +- Added support for handling control + arrows keys. + +Fixed: +- Correctly handle f1-f4 in rxvt-unicode. + +0.25: 2014-12-11 +---------------- + +Fixed: +- Package did not install on Python 2.6/2.7. + +0.24: 2014-12-10 +---------------- + +Backwards-incompatible changes: +- Completer.get_completions now gets a complete_event argument. + +New features: +- For ptpython: filename completion inside Python strings. +- prompt_toolkit.contrib.regular_languages added. +- prompt_toolkit.contrib.pdb added. (Experimental PDB front-end.) +- Support for multiline toolbars. +- asyncio support added. (Integration with asyncio event loop.) +- WORD parameter added to Document.word_before_cursor. + +Fixed: +- Small fixes in Win32 terminal output. +- Bug fix in parsing of CPR response. + +0.23: 2014-11-28 +---------------- + +New features: +- contrib.completers added. + +Fixed: +- Improved j/k key bindings in Vi mode. +- Don't leak internal variables into ptipython shell. +- Initialize IPython extensions. +- Use IPython's prompt. +- Workarounds for Jedi crashes. + +0.22: 2014-11-09 +---------------- + +Fixed: +- Fixed missing import which caused Ctrl-Z to crash. +- Show error message for ptipython when IPython is not installed. + +0.21: 2014-10-25 +---------------- +New features: +- Using entry_points in setup.py +- Experimental Win32 support added. + +Fixed: +- Behavior of 'r' and 'R' key bindings in Vi mode. +- Detect multiline correctly for ptpython when there are triple quoted strings. +- Some other small improvements. + + +0.20: 2014-10-04 +---------------- +Fixed: +- Workarounds for Jedi bugs. +- Better handling of window resize events. +- Fixed counter in ptipython prompt. +- Use IPythonInputSplitter.transform_cell for IPython syntax validation. +- Only insert newlines for open brackets if the cursor is at the end of the input string. + +New features: +- More Vi key bindings: 'B', 'W', 'E', 'aW', 'aw' and 'iW' +- ControlZ now suspends the process + + +0.19: 2014-09-30 +---------------- +Fixed: +- Handle Jedi crashes. +- Autocompletion in `ptipython` +- Input validation in `ptipython` +- Execution of system commands (in `ptpython`) in Python 3 +- Add current directory to sys.path for `ptpython`. +- Minimal jedi and six version in setup.py + +New features +- Python 2.6 support +- C-C> and C-C< indent and unindent emacs key bindings. +- `ptpython` can now also run python scripts, so aliasing of `ptpython` as + `python` will work better. + +0.18: 2014-09-29 +---------------- +- First official (beta) release. + + +Jan 25, 2014 +------------ +first commit diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e1720e0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2014, Jonathan Slenders +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. + +* Neither the name of the {organization} nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..20c6caa --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include *rst LICENSE CHANGELOG MANIFEST.in +recursive-include examples *.py +recursive-include tests *.py +prune examples/sample?/build diff --git a/PROJECTS.rst b/PROJECTS.rst new file mode 100644 index 0000000..863c727 --- /dev/null +++ b/PROJECTS.rst @@ -0,0 +1,68 @@ +Projects using `prompt_toolkit` +=============================== + +Shells: + +- `ptpython `_: Python REPL +- `ptpdb `_: Python debugger (pdb replacement) +- `pgcli `_: Postgres client. +- `mycli `_: MySql client. +- `litecli `_: SQLite client. +- `wharfee `_: A Docker command line. +- `xonsh `_: A Python-ish, BASHwards-compatible shell. +- `saws `_: A Supercharged AWS Command Line Interface. +- `cycli `_: A Command Line Interface for Cypher. +- `crash `_: Crate command line client. +- `vcli `_: Vertica client. +- `aws-shell `_: An integrated shell for working with the AWS CLI. +- `softlayer-python `_: A command-line interface to manage various SoftLayer products and services. +- `ipython `_: The IPython REPL +- `click-repl `_: Subcommand REPL for click apps. +- `haxor-news `_: A Hacker News CLI. +- `gitsome `_: A Git/Shell Autocompleter with GitHub Integration. +- `http-prompt `_: An interactive command-line HTTP client. +- `coconut `_: Functional programming in Python. +- `Ergonomica `_: A Bash alternative written in Python. +- `Kube-shell `_: Kubernetes shell: An integrated shell for working with the Kubernetes CLI +- `mssql-cli `_: A command-line client for Microsoft SQL Server. +- `robotframework-debuglibrary `_: A debug library and REPL for RobotFramework. +- `ptrepl `_: Run any command as REPL +- `clipwdmgr `_: Command Line Password Manager. +- `slacker `_: Easy access to the Slack API and admin of workspaces via REPL. +- `EdgeDB `_: The next generation object-relational database. +- `pywit `_: Python library for Wit.ai. +- `objection `_: Runtime Mobile Exploration. +- `habu `_: Python Network Hacking Toolkit. +- `nawano `_: Nano cryptocurrency wallet +- `athenacli `_: A CLI for AWS Athena. +- `vulcano `_: A framework for creating command-line applications that also runs in REPL mode. +- `kafka-shell `_: A supercharged shell for Apache Kafka. +- `starterTree `_: A command launcher organized in a tree structure with fuzzy autocompletion +- `git-delete-merged-branches `_: Command-line tool to delete merged Git branches + +Full screen applications: + +- `pymux `_: A terminal multiplexer (like tmux) in pure Python. +- `pyvim `_: A Vim clone in pure Python. +- `freud `_: REST client backed by SQLite for storing servers +- `pypager `_: A $PAGER in pure Python (like "less"). +- `kubeterminal `_: Kubectl helper tool. +- `pydoro `_: Pomodoro timer. +- `sanctuary-zero `_: A secure chatroom with zero logging and total transience. +- `Hummingbot `_: A Cryptocurrency Algorithmic Trading Platform +- `git-bbb `_: A `git blame` browser. + +Libraries: + +- `ptterm `_: A terminal emulator widget for prompt_toolkit. +- `PyInquirer `_: A Python library that wants to make it easy for existing Inquirer.js users to write immersive command line applications in Python. +- `clintermission `_: Non-fullscreen command-line selection menu + +Other libraries and implementations in other languages +****************************************************** + +- `go-prompt `_: building a powerful + interactive prompt in Go, inspired by python-prompt-toolkit. +- `urwid `_: Console user interface library for Python. + +(Want your own project to be listed here? Please create a GitHub issue.) diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..3cd6687 --- /dev/null +++ b/README.rst @@ -0,0 +1,146 @@ +Python Prompt Toolkit +===================== + +|AppVeyor| |PyPI| |RTD| |License| |Codecov| + +.. image :: https://github.com/prompt-toolkit/python-prompt-toolkit/raw/master/docs/images/logo_400px.png + +``prompt_toolkit`` *is a library for building powerful interactive command line applications in Python.* + +Read the `documentation on readthedocs +`_. + + +Gallery +******* + +`ptpython `_ is an interactive +Python Shell, build on top of ``prompt_toolkit``. + +.. image :: https://github.com/prompt-toolkit/python-prompt-toolkit/raw/master/docs/images/ptpython.png + +`More examples `_ + + +prompt_toolkit features +*********************** + +``prompt_toolkit`` could be a replacement for `GNU readline +`_, but it can be much +more than that. + +Some features: + +- **Pure Python**. +- Syntax highlighting of the input while typing. (For instance, with a Pygments lexer.) +- Multi-line input editing. +- Advanced code completion. +- Both Emacs and Vi key bindings. (Similar to readline.) +- Even some advanced Vi functionality, like named registers and digraphs. +- Reverse and forward incremental search. +- Works well with Unicode double width characters. (Chinese input.) +- Selecting text for copy/paste. (Both Emacs and Vi style.) +- Support for `bracketed paste `_. +- Mouse support for cursor positioning and scrolling. +- Auto suggestions. (Like `fish shell `_.) +- Multiple input buffers. +- No global state. +- Lightweight, the only dependencies are Pygments and wcwidth. +- Runs on Linux, OS X, FreeBSD, OpenBSD and Windows systems. +- And much more... + +Feel free to create tickets for bugs and feature requests, and create pull +requests if you have nice patches that you would like to share with others. + + +Installation +************ + +:: + + pip install prompt_toolkit + +For Conda, do: + +:: + + conda install -c https://conda.anaconda.org/conda-forge prompt_toolkit + + +About Windows support +********************* + +``prompt_toolkit`` is cross platform, and everything that you build on top +should run fine on both Unix and Windows systems. Windows support is best on +recent Windows 10 builds, for which the command line window supports vt100 +escape sequences. (If not supported, we fall back to using Win32 APIs for color +and cursor movements). + +It's worth noting that the implementation is a "best effort of what is +possible". Both Unix and Windows terminals have their limitations. But in +general, the Unix experience will still be a little better. + +For Windows, it's recommended to use either `cmder +`_ or `conemu `_. + +Getting started +*************** + +The most simple example of the library would look like this: + +.. code:: python + + from prompt_toolkit import prompt + + if __name__ == '__main__': + answer = prompt('Give me some input: ') + print('You said: %s' % answer) + +For more complex examples, have a look in the ``examples`` directory. All +examples are chosen to demonstrate only one thing. Also, don't be afraid to +look at the source code. The implementation of the ``prompt`` function could be +a good start. + +Philosophy +********** + +The source code of ``prompt_toolkit`` should be **readable**, **concise** and +**efficient**. We prefer short functions focusing each on one task and for which +the input and output types are clearly specified. We mostly prefer composition +over inheritance, because inheritance can result in too much functionality in +the same object. We prefer immutable objects where possible (objects don't +change after initialization). Reusability is important. We absolutely refrain +from having a changing global state, it should be possible to have multiple +independent instances of the same code in the same process. The architecture +should be layered: the lower levels operate on primitive operations and data +structures giving -- when correctly combined -- all the possible flexibility; +while at the higher level, there should be a simpler API, ready-to-use and +sufficient for most use cases. Thinking about algorithms and efficiency is +important, but avoid premature optimization. + + +`Projects using prompt_toolkit `_ +*********************************************** + +Special thanks to +***************** + +- `Pygments `_: Syntax highlighter. +- `wcwidth `_: Determine columns needed for a wide characters. + +.. |PyPI| image:: https://img.shields.io/pypi/v/prompt_toolkit.svg + :target: https://pypi.python.org/pypi/prompt-toolkit/ + :alt: Latest Version + +.. |AppVeyor| image:: https://ci.appveyor.com/api/projects/status/32r7s2skrgm9ubva?svg=true + :target: https://ci.appveyor.com/project/prompt-toolkit/python-prompt-toolkit/ + +.. |RTD| image:: https://readthedocs.org/projects/python-prompt-toolkit/badge/ + :target: https://python-prompt-toolkit.readthedocs.io/en/master/ + +.. |License| image:: https://img.shields.io/github/license/prompt-toolkit/python-prompt-toolkit.svg + :target: https://github.com/prompt-toolkit/python-prompt-toolkit/blob/master/LICENSE + +.. |Codecov| image:: https://codecov.io/gh/prompt-toolkit/python-prompt-toolkit/branch/master/graphs/badge.svg?style=flat + :target: https://codecov.io/gh/prompt-toolkit/python-prompt-toolkit/ + diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 0000000..892b186 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,45 @@ +environment: + matrix: + - PYTHON: "C:\\Python36" + PYTHON_VERSION: "3.6" + - PYTHON: "C:\\Python36-x64" + PYTHON_VERSION: "3.6" + + - PYTHON: "C:\\Python35" + PYTHON_VERSION: "3.5" + - PYTHON: "C:\\Python35-x64" + PYTHON_VERSION: "3.5" + + - PYTHON: "C:\\Python34" + PYTHON_VERSION: "3.4" + - PYTHON: "C:\\Python34-x64" + PYTHON_VERSION: "3.4" + + - PYTHON: "C:\\Python33" + PYTHON_VERSION: "3.3" + - PYTHON: "C:\\Python33-x64" + PYTHON_VERSION: "3.3" + + - PYTHON: "C:\\Python27" + PYTHON_VERSION: "2.7" + - PYTHON: "C:\\Python27-x64" + PYTHON_VERSION: "2.7" + + - PYTHON: "C:\\Python26" + PYTHON_VERSION: "2.6" + - PYTHON: "C:\\Python27-x64" + PYTHON_VERSION: "2.6" + +install: + - "set PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%" + - pip install . pytest coverage codecov flake8 + - pip list + +build: false + +test_script: + - If not ($env:PYTHON_VERSION==2.6) flake8 prompt_toolkit + - coverage run -m pytest + +after_test: + - codecov diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d5ddb2d --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,177 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/prompt_toolkit.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/prompt_toolkit.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/prompt_toolkit" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/prompt_toolkit" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..abf6db3 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,313 @@ +# +# prompt_toolkit documentation build configuration file, created by +# sphinx-quickstart on Thu Jul 31 14:17:08 2014. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +# needs_sphinx = '1.0' + +# 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"] + +# Add any paths that contain templates here, relative to this directory. +# templates_path = ["_templates"] + +# The suffix of source filenames. +source_suffix = ".rst" + +# The encoding of source files. +# source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = "index" + +# General information about the project. +project = "prompt_toolkit" +copyright = "2014-2023, Jonathan Slenders" + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +version = "3.0.43" +# The full version, including alpha/beta/rc tags. +release = "3.0.43" + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +# today = '' +# Else, today_fmt is used as the format for a strftime call. +# today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ["_build"] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +# default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +# add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +# add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +# show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = "pastie" +pygments_dark_style = "dracula" + +# A list of ignored prefixes for module index sorting. +# modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +# keep_warnings = False + +# autodoc configuration +# https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html +autodoc_inherit_docstrings = False +autodoc_mock_imports = [ + "prompt_toolkit.eventloop.win32", + "prompt_toolkit.input.win32", + "prompt_toolkit.output.win32", +] + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. + +# 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" + + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. + + +# Add any paths that contain custom themes here, relative to this directory. +# html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +# html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +# html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +html_logo = "images/logo_400px.png" + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +# html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +# html_static_path = ["_static"] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +# html_extra_path = [] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +# html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +# html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +# html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +# html_additional_pages = {} + +# If false, no module index is generated. +# html_domain_indices = True + +# If false, no index is generated. +# html_use_index = True + +# If true, the index is split into individual pages for each letter. +# html_split_index = False + +# If true, links to the reST sources are added to the pages. +# html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +# html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +# html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +# html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +# html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = "prompt_toolkitdoc" + + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # 'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + # 'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + # 'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + ( + "index", + "prompt_toolkit.tex", + "prompt_toolkit Documentation", + "Jonathan Slenders", + "manual", + ), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +# latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +# latex_use_parts = False + +# If true, show page references after internal links. +# latex_show_pagerefs = False + +# If true, show URL addresses after external links. +# latex_show_urls = False + +# Documents to append as an appendix to all manuals. +# latex_appendices = [] + +# If false, no module index is generated. +# latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ( + "index", + "prompt_toolkit", + "prompt_toolkit Documentation", + ["Jonathan Slenders"], + 1, + ) +] + +# If true, show URL addresses after external links. +# man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ( + "index", + "prompt_toolkit", + "prompt_toolkit Documentation", + "Jonathan Slenders", + "prompt_toolkit", + "One line description of project.", + "Miscellaneous", + ), +] + +# Documents to append as an appendix to all manuals. +# texinfo_appendices = [] + +# If false, no module index is generated. +# texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +# texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +# texinfo_no_detailmenu = False diff --git a/docs/images/auto-suggestion.png b/docs/images/auto-suggestion.png new file mode 100644 index 0000000..7671156 Binary files /dev/null and b/docs/images/auto-suggestion.png differ diff --git a/docs/images/bottom-toolbar.png b/docs/images/bottom-toolbar.png new file mode 100644 index 0000000..8d5f17c Binary files /dev/null and b/docs/images/bottom-toolbar.png differ diff --git a/docs/images/colored-prompt.png b/docs/images/colored-prompt.png new file mode 100644 index 0000000..bd85975 Binary files /dev/null and b/docs/images/colored-prompt.png differ diff --git a/docs/images/colorful-completions.png b/docs/images/colorful-completions.png new file mode 100644 index 0000000..c397815 Binary files /dev/null and b/docs/images/colorful-completions.png differ diff --git a/docs/images/dialogs/button.png b/docs/images/dialogs/button.png new file mode 100644 index 0000000..fb4b3bb Binary files /dev/null and b/docs/images/dialogs/button.png differ diff --git a/docs/images/dialogs/confirm.png b/docs/images/dialogs/confirm.png new file mode 100644 index 0000000..1b7d9ec Binary files /dev/null and b/docs/images/dialogs/confirm.png differ diff --git a/docs/images/dialogs/inputbox.png b/docs/images/dialogs/inputbox.png new file mode 100644 index 0000000..73ae79f Binary files /dev/null and b/docs/images/dialogs/inputbox.png differ diff --git a/docs/images/dialogs/messagebox.png b/docs/images/dialogs/messagebox.png new file mode 100644 index 0000000..3ac08c0 Binary files /dev/null and b/docs/images/dialogs/messagebox.png differ diff --git a/docs/images/dialogs/styled.png b/docs/images/dialogs/styled.png new file mode 100644 index 0000000..a359a7a Binary files /dev/null and b/docs/images/dialogs/styled.png differ diff --git a/docs/images/hello-world-prompt.png b/docs/images/hello-world-prompt.png new file mode 100644 index 0000000..17e7e1f Binary files /dev/null and b/docs/images/hello-world-prompt.png differ diff --git a/docs/images/html-completion.png b/docs/images/html-completion.png new file mode 100644 index 0000000..8fd73ad Binary files /dev/null and b/docs/images/html-completion.png differ diff --git a/docs/images/html-input.png b/docs/images/html-input.png new file mode 100644 index 0000000..1f4b11b Binary files /dev/null and b/docs/images/html-input.png differ diff --git a/docs/images/logo_400px.png b/docs/images/logo_400px.png new file mode 100755 index 0000000..5d90d44 Binary files /dev/null and b/docs/images/logo_400px.png differ diff --git a/docs/images/multiline-input.png b/docs/images/multiline-input.png new file mode 100644 index 0000000..7d72844 Binary files /dev/null and b/docs/images/multiline-input.png differ diff --git a/docs/images/number-validator.png b/docs/images/number-validator.png new file mode 100644 index 0000000..5a12c89 Binary files /dev/null and b/docs/images/number-validator.png differ diff --git a/docs/images/progress-bars/apt-get.png b/docs/images/progress-bars/apt-get.png new file mode 100755 index 0000000..ce62464 Binary files /dev/null and b/docs/images/progress-bars/apt-get.png differ diff --git a/docs/images/progress-bars/colored-title-and-label.png b/docs/images/progress-bars/colored-title-and-label.png new file mode 100755 index 0000000..ace4393 Binary files /dev/null and b/docs/images/progress-bars/colored-title-and-label.png differ diff --git a/docs/images/progress-bars/custom-key-bindings.png b/docs/images/progress-bars/custom-key-bindings.png new file mode 100755 index 0000000..5f3610c Binary files /dev/null and b/docs/images/progress-bars/custom-key-bindings.png differ diff --git a/docs/images/progress-bars/simple-progress-bar.png b/docs/images/progress-bars/simple-progress-bar.png new file mode 100755 index 0000000..6ea3eac Binary files /dev/null and b/docs/images/progress-bars/simple-progress-bar.png differ diff --git a/docs/images/progress-bars/two-tasks.png b/docs/images/progress-bars/two-tasks.png new file mode 100755 index 0000000..0bb3f75 Binary files /dev/null and b/docs/images/progress-bars/two-tasks.png differ diff --git a/docs/images/ptpython-2.png b/docs/images/ptpython-2.png new file mode 100644 index 0000000..0fce32c Binary files /dev/null and b/docs/images/ptpython-2.png differ diff --git a/docs/images/ptpython-history-help.png b/docs/images/ptpython-history-help.png new file mode 100644 index 0000000..a52b5c2 Binary files /dev/null and b/docs/images/ptpython-history-help.png differ diff --git a/docs/images/ptpython-menu.png b/docs/images/ptpython-menu.png new file mode 100644 index 0000000..923ca12 Binary files /dev/null and b/docs/images/ptpython-menu.png differ diff --git a/docs/images/ptpython.png b/docs/images/ptpython.png new file mode 100644 index 0000000..c46b55a Binary files /dev/null and b/docs/images/ptpython.png differ diff --git a/docs/images/pymux.png b/docs/images/pymux.png new file mode 100644 index 0000000..7e8d73a Binary files /dev/null and b/docs/images/pymux.png differ diff --git a/docs/images/pyvim.png b/docs/images/pyvim.png new file mode 100644 index 0000000..f78000f Binary files /dev/null and b/docs/images/pyvim.png differ diff --git a/docs/images/repl/sqlite-1.png b/docs/images/repl/sqlite-1.png new file mode 100644 index 0000000..0511daa Binary files /dev/null and b/docs/images/repl/sqlite-1.png differ diff --git a/docs/images/repl/sqlite-2.png b/docs/images/repl/sqlite-2.png new file mode 100644 index 0000000..47b0238 Binary files /dev/null and b/docs/images/repl/sqlite-2.png differ diff --git a/docs/images/repl/sqlite-3.png b/docs/images/repl/sqlite-3.png new file mode 100644 index 0000000..cdee9d2 Binary files /dev/null and b/docs/images/repl/sqlite-3.png differ diff --git a/docs/images/repl/sqlite-4.png b/docs/images/repl/sqlite-4.png new file mode 100644 index 0000000..c6ee929 Binary files /dev/null and b/docs/images/repl/sqlite-4.png differ diff --git a/docs/images/repl/sqlite-5.png b/docs/images/repl/sqlite-5.png new file mode 100644 index 0000000..d1964e0 Binary files /dev/null and b/docs/images/repl/sqlite-5.png differ diff --git a/docs/images/repl/sqlite-6.png b/docs/images/repl/sqlite-6.png new file mode 100644 index 0000000..054beb0 Binary files /dev/null and b/docs/images/repl/sqlite-6.png differ diff --git a/docs/images/rprompt.png b/docs/images/rprompt.png new file mode 100644 index 0000000..a44f0e9 Binary files /dev/null and b/docs/images/rprompt.png differ diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..569e14c --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,89 @@ +Python Prompt Toolkit 3.0 +========================= + +`prompt_toolkit` is a library for building powerful interactive command line +and terminal applications in Python. + + +It can be a very advanced pure Python replacement for `GNU readline +`_, but it can also be +used for building full screen applications. + +.. image:: images/ptpython-2.png + +Some features: + +- Syntax highlighting of the input while typing. (For instance, with a Pygments lexer.) +- Multi-line input editing. +- Advanced code completion. +- Selecting text for copy/paste. (Both Emacs and Vi style.) +- Mouse support for cursor positioning and scrolling. +- Auto suggestions. (Like `fish shell `_.) +- No global state. + +Like readline: + +- Both Emacs and Vi key bindings. +- Reverse and forward incremental search. +- Works well with Unicode double width characters. (Chinese input.) + +Works everywhere: + +- Pure Python. Runs on all Python versions starting at Python 3.6. + (Python 2.6 - 3.x is supported in prompt_toolkit 2.0; not 3.0). +- Runs on Linux, OS X, OpenBSD and Windows systems. +- Lightweight, the only dependencies are Pygments and wcwidth. +- No assumptions about I/O are made. Every prompt_toolkit application should + also run in a telnet/ssh server or an `asyncio + `_ process. + +Have a look at :ref:`the gallery ` to get an idea of what is possible. + +Getting started +--------------- + +Go to :ref:`getting started ` and build your first prompt. +Issues are tracked `on the Github project +`_. + + +Thanks to: +---------- + +A special thanks to `all the contributors +`_ +for making prompt_toolkit possible. + +Also, a special thanks to the `Pygments `_ and `wcwidth +`_ libraries. + + +Table of contents +----------------- + +.. toctree:: + :maxdepth: 2 + + pages/gallery + pages/getting_started + pages/upgrading/index + pages/printing_text + pages/asking_for_input + pages/dialogs + pages/progress_bars + pages/full_screen_apps + pages/tutorials/index + pages/advanced_topics/index + pages/reference + pages/related_projects + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + +Prompt_toolkit was created by `Jonathan Slenders +`_. diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..18ae022 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,242 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +set I18NSPHINXOPTS=%SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. xml to make Docutils-native XML files + echo. pseudoxml to make pseudoxml-XML files for display purposes + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + + +%SPHINXBUILD% 2> nul +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\xline.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\xline.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdf" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf + cd %BUILDDIR%/.. + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdfja" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf-ja + cd %BUILDDIR%/.. + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +if "%1" == "xml" ( + %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The XML files are in %BUILDDIR%/xml. + goto end +) + +if "%1" == "pseudoxml" ( + %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. + goto end +) + +:end diff --git a/docs/pages/advanced_topics/architecture.rst b/docs/pages/advanced_topics/architecture.rst new file mode 100644 index 0000000..c690104 --- /dev/null +++ b/docs/pages/advanced_topics/architecture.rst @@ -0,0 +1,97 @@ +.. _architecture: + + +Architecture +============ + +TODO: this is a little outdated. + +:: + + +---------------------------------------------------------------+ + | InputStream | + | =========== | + | - Parses the input stream coming from a VT100 | + | compatible terminal. Translates it into data input | + | and control characters. Calls the corresponding | + | handlers of the `InputStreamHandler` instance. | + | | + | e.g. Translate '\x1b[6~' into "Keys.PageDown", call | + | the `feed_key` method of `InputProcessor`. | + +---------------------------------------------------------------+ + | + v + +---------------------------------------------------------------+ + | InputStreamHandler | + | ================== | + | - Has a `Registry` of key bindings, it calls the | + | bindings according to the received keys and the | + | input mode. | + | | + | We have Vi and Emacs bindings. + +---------------------------------------------------------------+ + | + v + +---------------------------------------------------------------+ + | Key bindings | + | ============ | + | - Every key binding consists of a function that | + | receives an `Event` and usually it operates on | + | the `Buffer` object. (It could insert data or | + | move the cursor for example.) | + +---------------------------------------------------------------+ + | + | Most of the key bindings operate on a `Buffer` object, but + | they don't have to. They could also change the visibility + | of a menu for instance, or change the color scheme. + | + v + +---------------------------------------------------------------+ + | Buffer | + | ====== | + | - Contains a data structure to hold the current | + | input (text and cursor position). This class | + | implements all text manipulations and cursor | + | movements (Like e.g. cursor_forward, insert_char | + | or delete_word.) | + | | + | +-----------------------------------------------+ | + | | Document (text, cursor_position) | | + | | ================================ | | + | | Accessed as the `document` property of the | | + | | `Buffer` class. This is a wrapper around the | | + | | text and cursor position, and contains | | + | | methods for querying this data , e.g. to give | | + | | the text before the cursor. | | + | +-----------------------------------------------+ | + +---------------------------------------------------------------+ + | + | Normally after every key press, the output will be + | rendered again. This happens in the event loop of + | the `Application` where `Renderer.render` is called. + v + +---------------------------------------------------------------+ + | Layout | + | ====== | + | - When the renderer should redraw, the renderer | + | asks the layout what the output should look like. | + | - The layout operates on a `Screen` object that he | + | received from the `Renderer` and will put the | + | toolbars, menus, highlighted content and prompt | + | in place. | + | | + | +-----------------------------------------------+ | + | | Menus, toolbars, prompt | | + | | ======================= | | + | | | | + | +-----------------------------------------------+ | + +---------------------------------------------------------------+ + | + v + +---------------------------------------------------------------+ + | Renderer | + | ======== | + | - Calculates the difference between the last output | + | and the new one and writes it to the terminal | + | output. | + +---------------------------------------------------------------+ diff --git a/docs/pages/advanced_topics/asyncio.rst b/docs/pages/advanced_topics/asyncio.rst new file mode 100644 index 0000000..a692630 --- /dev/null +++ b/docs/pages/advanced_topics/asyncio.rst @@ -0,0 +1,30 @@ +.. _asyncio: + +Running on top of the `asyncio` event loop +========================================== + +.. note:: + + New in prompt_toolkit 3.0. (In prompt_toolkit 2.0 this was possible using a + work-around). + +Prompt_toolkit 3.0 uses asyncio natively. Calling ``Application.run()`` will +automatically run the asyncio event loop. + +If however you want to run a prompt_toolkit ``Application`` within an asyncio +environment, you have to call the ``run_async`` method, like this: + +.. code:: python + + from prompt_toolkit.application import Application + + async def main(): + # Define application. + application = Application( + ... + ) + + result = await application.run_async() + print(result) + + asyncio.get_event_loop().run_until_complete(main()) diff --git a/docs/pages/advanced_topics/filters.rst b/docs/pages/advanced_topics/filters.rst new file mode 100644 index 0000000..4788769 --- /dev/null +++ b/docs/pages/advanced_topics/filters.rst @@ -0,0 +1,169 @@ +.. _filters: + +Filters +======= + +Many places in `prompt_toolkit` require a boolean value that can change over +time. For instance: + +- to specify whether a part of the layout needs to be visible or not; +- or to decide whether a certain key binding needs to be active or not; +- or the ``wrap_lines`` option of + :class:`~prompt_toolkit.layout.BufferControl`; +- etcetera. + +These booleans are often dynamic and can change at runtime. For instance, the +search toolbar should only be visible when the user is actually searching (when +the search buffer has the focus). The ``wrap_lines`` option could be changed +with a certain key binding. And that key binding could only work when the +default buffer got the focus. + +In `prompt_toolkit`, we decided to reduce the amount of state in the whole +framework, and apply a simple kind of reactive programming to describe the flow +of these booleans as expressions. (It's one-way only: if a key binding needs to +know whether it's active or not, it can follow this flow by evaluating an +expression.) + +The (abstract) base class is :class:`~prompt_toolkit.filters.Filter`, which +wraps an expression that takes no input and evaluates to a boolean. Getting the +state of a filter is done by simply calling it. + + +An example +---------- + +The most obvious way to create such a :class:`~prompt_toolkit.filters.Filter` +instance is by creating a :class:`~prompt_toolkit.filters.Condition` instance +from a function. For instance, the following condition will evaluate to +``True`` when the user is searching: + +.. code:: python + + from prompt_toolkit.application.current import get_app + from prompt_toolkit.filters import Condition + + is_searching = Condition(lambda: get_app().is_searching) + +A different way of writing this, is by using the decorator syntax: + +.. code:: python + + from prompt_toolkit.application.current import get_app + from prompt_toolkit.filters import Condition + + @Condition + def is_searching(): + return get_app().is_searching + +This filter can then be used in a key binding, like in the following snippet: + +.. code:: python + + from prompt_toolkit.key_binding import KeyBindings + + kb = KeyBindings() + + @kb.add('c-t', filter=is_searching) + def _(event): + # Do, something, but only when searching. + pass + +If we want to know the boolean value of this filter, we have to call it like a +function: + +.. code:: python + + print(is_searching()) + + +Built-in filters +---------------- + +There are many built-in filters, ready to use. All of them have a lowercase +name, because they represent the wrapped function underneath, and can be called +as a function. + +- :class:`~prompt_toolkit.filters.app.has_arg` +- :class:`~prompt_toolkit.filters.app.has_completions` +- :class:`~prompt_toolkit.filters.app.has_focus` +- :class:`~prompt_toolkit.filters.app.buffer_has_focus` +- :class:`~prompt_toolkit.filters.app.has_selection` +- :class:`~prompt_toolkit.filters.app.has_validation_error` +- :class:`~prompt_toolkit.filters.app.is_aborting` +- :class:`~prompt_toolkit.filters.app.is_done` +- :class:`~prompt_toolkit.filters.app.is_read_only` +- :class:`~prompt_toolkit.filters.app.is_multiline` +- :class:`~prompt_toolkit.filters.app.renderer_height_is_known` +- :class:`~prompt_toolkit.filters.app.in_editing_mode` +- :class:`~prompt_toolkit.filters.app.in_paste_mode` + +- :class:`~prompt_toolkit.filters.app.vi_mode` +- :class:`~prompt_toolkit.filters.app.vi_navigation_mode` +- :class:`~prompt_toolkit.filters.app.vi_insert_mode` +- :class:`~prompt_toolkit.filters.app.vi_insert_multiple_mode` +- :class:`~prompt_toolkit.filters.app.vi_replace_mode` +- :class:`~prompt_toolkit.filters.app.vi_selection_mode` +- :class:`~prompt_toolkit.filters.app.vi_waiting_for_text_object_mode` +- :class:`~prompt_toolkit.filters.app.vi_digraph_mode` + +- :class:`~prompt_toolkit.filters.app.emacs_mode` +- :class:`~prompt_toolkit.filters.app.emacs_insert_mode` +- :class:`~prompt_toolkit.filters.app.emacs_selection_mode` + +- :class:`~prompt_toolkit.filters.app.is_searching` +- :class:`~prompt_toolkit.filters.app.control_is_searchable` +- :class:`~prompt_toolkit.filters.app.vi_search_direction_reversed` + + +Combining filters +----------------- + +Filters can be chained with the ``&`` (AND) and ``|`` (OR) operators and +negated with the ``~`` (negation) operator. + +Some examples: + +.. code:: python + + from prompt_toolkit.key_binding import KeyBindings + from prompt_toolkit.filters import has_selection, has_selection + + kb = KeyBindings() + + @kb.add('c-t', filter=~is_searching) + def _(event): + " Do something, but not while searching. " + pass + + @kb.add('c-t', filter=has_search | has_selection) + def _(event): + " Do something, but only when searching or when there is a selection. " + pass + + +to_filter +--------- + +Finally, in many situations you want your code to expose an API that is able to +deal with both booleans as well as filters. For instance, when for most users a +boolean works fine because they don't need to change the value over time, while +some advanced users want to be able this value to a certain setting or event +that does changes over time. + +In order to handle both use cases, there is a utility called +:func:`~prompt_toolkit.filters.utils.to_filter`. + +This is a function that takes +either a boolean or an actual :class:`~prompt_toolkit.filters.Filter` +instance, and always returns a :class:`~prompt_toolkit.filters.Filter`. + +.. code:: python + + from prompt_toolkit.filters.utils import to_filter + + # In each of the following three examples, 'f' will be a `Filter` + # instance. + f = to_filter(True) + f = to_filter(False) + f = to_filter(Condition(lambda: True)) + f = to_filter(has_search | has_selection) diff --git a/docs/pages/advanced_topics/index.rst b/docs/pages/advanced_topics/index.rst new file mode 100644 index 0000000..4c4fcc9 --- /dev/null +++ b/docs/pages/advanced_topics/index.rst @@ -0,0 +1,18 @@ +.. _advanced_topics: + +Advanced topics +=============== + +.. toctree:: + :caption: Contents: + :maxdepth: 1 + + key_bindings + styling + filters + rendering_flow + asyncio + unit_testing + input_hooks + architecture + rendering_pipeline diff --git a/docs/pages/advanced_topics/input_hooks.rst b/docs/pages/advanced_topics/input_hooks.rst new file mode 100644 index 0000000..16c1bf5 --- /dev/null +++ b/docs/pages/advanced_topics/input_hooks.rst @@ -0,0 +1,41 @@ +.. _input_hooks: + + +Input hooks +=========== + +Input hooks are a tool for inserting an external event loop into the +prompt_toolkit event loop, so that the other loop can run as long as +prompt_toolkit (actually asyncio) is idle. This is used in applications like +`IPython `_, so that GUI toolkits can display their +windows while we wait at the prompt for user input. + +As a consequence, we will "trampoline" back and forth between two event loops. + +.. note:: + + This will use a :class:`~asyncio.SelectorEventLoop`, not the :class: + :class:`~asyncio.ProactorEventLoop` (on Windows) due to the way the + implementation works (contributions are welcome to make that work). + + +.. code:: python + + from prompt_toolkit.eventloop.inputhook import set_eventloop_with_inputhook + + def inputhook(inputhook_context): + # At this point, we run the other loop. This loop is supposed to run + # until either `inputhook_context.fileno` becomes ready for reading or + # `inputhook_context.input_is_ready()` returns True. + + # A good way is to register this file descriptor in this other event + # loop with a callback that stops this loop when this FD becomes ready. + # There is no need to actually read anything from the FD. + + while True: + ... + + set_eventloop_with_inputhook(inputhook) + + # Any asyncio code at this point will now use this new loop, with input + # hook installed. diff --git a/docs/pages/advanced_topics/key_bindings.rst b/docs/pages/advanced_topics/key_bindings.rst new file mode 100644 index 0000000..8b334fc --- /dev/null +++ b/docs/pages/advanced_topics/key_bindings.rst @@ -0,0 +1,388 @@ +.. _key_bindings: + +More about key bindings +======================= + +This page contains a few additional notes about key bindings. + + +Key bindings can be defined as follows by creating a +:class:`~prompt_toolkit.key_binding.KeyBindings` instance: + + +.. code:: python + + from prompt_toolkit.key_binding import KeyBindings + + bindings = KeyBindings() + + @bindings.add('a') + def _(event): + " Do something if 'a' has been pressed. " + ... + + + @bindings.add('c-t') + def _(event): + " Do something if Control-T has been pressed. " + ... + +.. note:: + + :kbd:`c-q` (control-q) and :kbd:`c-s` (control-s) are often captured by the + terminal, because they were used traditionally for software flow control. + When this is enabled, the application will automatically freeze when + :kbd:`c-s` is pressed, until :kbd:`c-q` is pressed. It won't be possible to + bind these keys. + + In order to disable this, execute the following command in your shell, or even + add it to your `.bashrc`. + + .. code:: + + stty -ixon + +Key bindings can even consist of a sequence of multiple keys. The binding is +only triggered when all the keys in this sequence are pressed. + +.. code:: python + + @bindings.add('a', 'b') + def _(event): + " Do something if 'a' is pressed and then 'b' is pressed. " + ... + +If the user presses only `a`, then nothing will happen until either a second +key (like `b`) has been pressed or until the timeout expires (see later). + + +List of special keys +-------------------- + +Besides literal characters, any of the following keys can be used in a key +binding: + ++-------------------+-----------------------------------------+ +| Name + Possible keys | ++===================+=========================================+ +| Escape | :kbd:`escape` | +| Shift + escape | :kbd:`s-escape` | ++-------------------+-----------------------------------------+ +| Arrows | :kbd:`left`, | +| | :kbd:`right`, | +| | :kbd:`up`, | +| | :kbd:`down` | ++-------------------+-----------------------------------------+ +| Navigation | :kbd:`home`, | +| | :kbd:`end`, | +| | :kbd:`delete`, | +| | :kbd:`pageup`, | +| | :kbd:`pagedown`, | +| | :kbd:`insert` | ++-------------------+-----------------------------------------+ +| Control+letter | :kbd:`c-a`, :kbd:`c-b`, :kbd:`c-c`, | +| | :kbd:`c-d`, :kbd:`c-e`, :kbd:`c-f`, | +| | :kbd:`c-g`, :kbd:`c-h`, :kbd:`c-i`, | +| | :kbd:`c-j`, :kbd:`c-k`, :kbd:`c-l`, | +| | | +| | :kbd:`c-m`, :kbd:`c-n`, :kbd:`c-o`, | +| | :kbd:`c-p`, :kbd:`c-q`, :kbd:`c-r`, | +| | :kbd:`c-s`, :kbd:`c-t`, :kbd:`c-u`, | +| | :kbd:`c-v`, :kbd:`c-w`, :kbd:`c-x`, | +| | | +| | :kbd:`c-y`, :kbd:`c-z` | ++-------------------+-----------------------------------------+ +| Control + number | :kbd:`c-1`, :kbd:`c-2`, :kbd:`c-3`, | +| | :kbd:`c-4`, :kbd:`c-5`, :kbd:`c-6`, | +| | :kbd:`c-7`, :kbd:`c-8`, :kbd:`c-9`, | +| | :kbd:`c-0` | ++-------------------+-----------------------------------------+ +| Control + arrow | :kbd:`c-left`, | +| | :kbd:`c-right`, | +| | :kbd:`c-up`, | +| | :kbd:`c-down` | ++-------------------+-----------------------------------------+ +| Other control | :kbd:`c-@`, | +| keys | :kbd:`c-\\`, | +| | :kbd:`c-]`, | +| | :kbd:`c-^`, | +| | :kbd:`c-_`, | +| | :kbd:`c-delete` | ++-------------------+-----------------------------------------+ +| Shift + arrow | :kbd:`s-left`, | +| | :kbd:`s-right`, | +| | :kbd:`s-up`, | +| | :kbd:`s-down` | ++-------------------+-----------------------------------------+ +| Control + Shift + | :kbd:`c-s-left`, | +| arrow | :kbd:`c-s-right`, | +| | :kbd:`c-s-up`, | +| | :kbd:`c-s-down` | ++-------------------+-----------------------------------------+ +| Other shift | :kbd:`s-delete`, | +| keys | :kbd:`s-tab` | ++-------------------+-----------------------------------------+ +| F-keys | :kbd:`f1`, :kbd:`f2`, :kbd:`f3`, | +| | :kbd:`f4`, :kbd:`f5`, :kbd:`f6`, | +| | :kbd:`f7`, :kbd:`f8`, :kbd:`f9`, | +| | :kbd:`f10`, :kbd:`f11`, :kbd:`f12`, | +| | | +| | :kbd:`f13`, :kbd:`f14`, :kbd:`f15`, | +| | :kbd:`f16`, :kbd:`f17`, :kbd:`f18`, | +| | :kbd:`f19`, :kbd:`f20`, :kbd:`f21`, | +| | :kbd:`f22`, :kbd:`f23`, :kbd:`f24` | ++-------------------+-----------------------------------------+ + +There are a couple of useful aliases as well: + ++-------------------+-------------------+ +| :kbd:`c-h` | :kbd:`backspace` | ++-------------------+-------------------+ +| :kbd:`c-@` | :kbd:`c-space` | ++-------------------+-------------------+ +| :kbd:`c-m` | :kbd:`enter` | ++-------------------+-------------------+ +| :kbd:`c-i` | :kbd:`tab` | ++-------------------+-------------------+ + +.. note:: + + Note that the supported keys are limited to what typical VT100 terminals + offer. Binding :kbd:`c-7` (control + number 7) for instance is not + supported. + + +Binding alt+something, option+something or meta+something +--------------------------------------------------------- + +Vt100 terminals translate the alt key into a leading :kbd:`escape` key. +For instance, in order to handle :kbd:`alt-f`, we have to handle +:kbd:`escape` + :kbd:`f`. Notice that we receive this as two individual keys. +This means that it's exactly the same as first typing :kbd:`escape` and then +typing :kbd:`f`. Something this alt-key is also known as option or meta. + +In code that looks as follows: + +.. code:: python + + @bindings.add('escape', 'f') + def _(event): + " Do something if alt-f or meta-f have been pressed. " + + +Wildcards +--------- + +Sometimes you want to catch any key that follows after a certain key stroke. +This is possible by binding the '' key: + +.. code:: python + + @bindings.add('a', '') + def _(event): + ... + +This will handle `aa`, `ab`, `ac`, etcetera. The key binding can check the +`event` object for which keys exactly have been pressed. + + +Attaching a filter (condition) +------------------------------ + +In order to enable a key binding according to a certain condition, we have to +pass it a :class:`~prompt_toolkit.filters.Filter`, usually a +:class:`~prompt_toolkit.filters.Condition` instance. (:ref:`Read more about +filters `.) + +.. code:: python + + from prompt_toolkit.filters import Condition + + @Condition + def is_active(): + " Only activate key binding on the second half of each minute. " + return datetime.datetime.now().second > 30 + + @bindings.add('c-t', filter=is_active) + def _(event): + # ... + pass + +The key binding will be ignored when this condition is not satisfied. + + +ConditionalKeyBindings: Disabling a set of key bindings +------------------------------------------------------- + +Sometimes you want to enable or disable a whole set of key bindings according +to a certain condition. This is possible by wrapping it in a +:class:`~prompt_toolkit.key_binding.ConditionalKeyBindings` object. + +.. code:: python + + from prompt_toolkit.key_binding import ConditionalKeyBindings + + @Condition + def is_active(): + " Only activate key binding on the second half of each minute. " + return datetime.datetime.now().second > 30 + + bindings = ConditionalKeyBindings( + key_bindings=my_bindings, + filter=is_active) + +If the condition is not satisfied, all the key bindings in `my_bindings` above +will be ignored. + + +Merging key bindings +-------------------- + +Sometimes you have different parts of your application generate a collection of +key bindings. It is possible to merge them together through the +:func:`~prompt_toolkit.key_binding.merge_key_bindings` function. This is +preferred above passing a :class:`~prompt_toolkit.key_binding.KeyBindings` +object around and having everyone populate it. + +.. code:: python + + from prompt_toolkit.key_binding import merge_key_bindings + + bindings = merge_key_bindings([ + bindings1, + bindings2, + ]) + + +Eager +----- + +Usually not required, but if ever you have to override an existing key binding, +the `eager` flag can be useful. + +Suppose that there is already an active binding for `ab` and you'd like to add +a second binding that only handles `a`. When the user presses only `a`, +prompt_toolkit has to wait for the next key press in order to know which +handler to call. + +By passing the `eager` flag to this second binding, we are actually saying that +prompt_toolkit shouldn't wait for longer matches when all the keys in this key +binding are matched. So, if `a` has been pressed, this second binding will be +called, even if there's an active `ab` binding. + +.. code:: python + + @bindings.add('a', 'b') + def binding_1(event): + ... + + @bindings.add('a', eager=True) + def binding_2(event): + ... + +This is mainly useful in order to conditionally override another binding. + +Asyncio coroutines +------------------ + +Key binding handlers can be asyncio coroutines. + +.. code:: python + + from prompt_toolkit.application import in_terminal + + @bindings.add('x') + async def print_hello(event): + """ + Pressing 'x' will print 5 times "hello" in the background above the + prompt. + """ + for i in range(5): + # Print hello above the current prompt. + async with in_terminal(): + print('hello') + + # Sleep, but allow further input editing in the meantime. + await asyncio.sleep(1) + +If the user accepts the input on the prompt, while this coroutine is not yet +finished , an `asyncio.CancelledError` exception will be thrown in this +coroutine. + + +Timeouts +-------- + +There are two timeout settings that effect the handling of keys. + +- ``Application.ttimeoutlen``: Like Vim's `ttimeoutlen` option. + When to flush the input (For flushing escape keys.) This is important on + terminals that use vt100 input. We can't distinguish the escape key from for + instance the left-arrow key, if we don't know what follows after "\x1b". This + little timer will consider "\x1b" to be escape if nothing did follow in this + time span. This seems to work like the `ttimeoutlen` option in Vim. + +- ``KeyProcessor.timeoutlen``: like Vim's `timeoutlen` option. + This can be `None` or a float. For instance, suppose that we have a key + binding AB and a second key binding A. If the uses presses A and then waits, + we don't handle this binding yet (unless it was marked 'eager'), because we + don't know what will follow. This timeout is the maximum amount of time that + we wait until we call the handlers anyway. Pass `None` to disable this + timeout. + + +Recording macros +---------------- + +Both Emacs and Vi mode allow macro recording. By default, all key presses are +recorded during a macro, but it is possible to exclude certain keys by setting +the `record_in_macro` parameter to `False`: + +.. code:: python + + @bindings.add('c-t', record_in_macro=False) + def _(event): + # ... + pass + + +Creating new Vi text objects and operators +------------------------------------------ + +We tried very hard to ship prompt_toolkit with as many as possible Vi text +objects and operators, so that text editing feels as natural as possible to Vi +users. + +If you wish to create a new text object or key binding, that is actually +possible. Check the `custom-vi-operator-and-text-object.py` example for more +information. + + +Handling SIGINT +--------------- + +The SIGINT Unix signal can be handled by binding ````. For instance: + +.. code:: python + + @bindings.add('') + def _(event): + # ... + pass + +This will handle a SIGINT that was sent by an external application into the +process. Handling control-c should be done by binding ``c-c``. (The terminal +input is set to raw mode, which means that a ``c-c`` won't be translated into a +SIGINT.) + +For a ``PromptSession``, there is a default binding for ```` that +corresponds to ``c-c``: it will exit the prompt, raising a +``KeyboardInterrupt`` exception. + + +Processing `.inputrc` +--------------------- + +GNU readline can be configured using an `.inputrc` configuration file. This file +contains key bindings as well as certain settings. Right now, prompt_toolkit +doesn't support `.inputrc`, but it should be possible in the future. diff --git a/docs/pages/advanced_topics/rendering_flow.rst b/docs/pages/advanced_topics/rendering_flow.rst new file mode 100644 index 0000000..0cd12c7 --- /dev/null +++ b/docs/pages/advanced_topics/rendering_flow.rst @@ -0,0 +1,86 @@ +.. _rendering_flow: + +The rendering flow +================== + +Understanding the rendering flow is important for understanding how +:class:`~prompt_toolkit.layout.Container` and +:class:`~prompt_toolkit.layout.UIControl` objects interact. We will demonstrate +it by explaining the flow around a +:class:`~prompt_toolkit.layout.BufferControl`. + +.. note:: + + A :class:`~prompt_toolkit.layout.BufferControl` is a + :class:`~prompt_toolkit.layout.UIControl` for displaying the content of a + :class:`~prompt_toolkit.buffer.Buffer`. A buffer is the object that holds + any editable region of text. Like all controls, it has to be wrapped into a + :class:`~prompt_toolkit.layout.Window`. + +Let's take the following code: + +.. code:: python + + from prompt_toolkit.enums import DEFAULT_BUFFER + from prompt_toolkit.layout.containers import Window + from prompt_toolkit.layout.controls import BufferControl + from prompt_toolkit.buffer import Buffer + + b = Buffer(name=DEFAULT_BUFFER) + Window(content=BufferControl(buffer=b)) + +What happens when a :class:`~prompt_toolkit.renderer.Renderer` objects wants a +:class:`~prompt_toolkit.layout.Container` to be rendered on a certain +:class:`~prompt_toolkit.layout.screen.Screen`? + +The visualization happens in several steps: + +1. The :class:`~prompt_toolkit.renderer.Renderer` calls the + :meth:`~prompt_toolkit.layout.Container.write_to_screen` method + of a :class:`~prompt_toolkit.layout.Container`. + This is a request to paint the layout in a rectangle of a certain size. + + The :class:`~prompt_toolkit.layout.Window` object then requests + the :class:`~prompt_toolkit.layout.UIControl` to create a + :class:`~prompt_toolkit.layout.UIContent` instance (by calling + :meth:`~prompt_toolkit.layout.UIControl.create_content`). + The user control receives the dimensions of the window, but can still + decide to create more or less content. + + Inside the :meth:`~prompt_toolkit.layout.UIControl.create_content` + method of :class:`~prompt_toolkit.layout.UIControl`, there are several + steps: + + 2. First, the buffer's text is passed to the + :meth:`~prompt_toolkit.lexers.Lexer.lex_document` method of a + :class:`~prompt_toolkit.lexers.Lexer`. This returns a function which + for a given line number, returns a "formatted text list" for that line + (that's a list of ``(style_string, text)`` tuples). + + 3. This list is passed through a list of + :class:`~prompt_toolkit.layout.processors.Processor` objects. + Each processor can do a transformation for each line. + (For instance, they can insert or replace some text, highlight the + selection or search string, etc...) + + 4. The :class:`~prompt_toolkit.layout.UIControl` returns a + :class:`~prompt_toolkit.layout.UIContent` instance which + generates such a token lists for each lines. + +The :class:`~prompt_toolkit.layout.Window` receives the +:class:`~prompt_toolkit.layout.UIContent` and then: + +5. It calculates the horizontal and vertical scrolling, if applicable + (if the content would take more space than what is available). + +6. The content is copied to the correct absolute position + :class:`~prompt_toolkit.layout.screen.Screen`, as requested by the + :class:`~prompt_toolkit.renderer.Renderer`. While doing this, the + :class:`~prompt_toolkit.layout.Window` can possible wrap the + lines, if line wrapping was configured. + +Note that this process is lazy: if a certain line is not displayed in the +:class:`~prompt_toolkit.layout.Window`, then it is not requested +from the :class:`~prompt_toolkit.layout.UIContent`. And from there, the line is +not passed through the processors or even asked from the +:class:`~prompt_toolkit.lexers.Lexer`. diff --git a/docs/pages/advanced_topics/rendering_pipeline.rst b/docs/pages/advanced_topics/rendering_pipeline.rst new file mode 100644 index 0000000..6b38ba5 --- /dev/null +++ b/docs/pages/advanced_topics/rendering_pipeline.rst @@ -0,0 +1,157 @@ +The rendering pipeline +====================== + +This document is an attempt to describe how prompt_toolkit applications are +rendered. It's a complex but logical process that happens more or less after +every key stroke. We'll go through all the steps from the point where the user +hits a key, until the character appears on the screen. + + +Waiting for user input +---------------------- + +Most of the time when a prompt_toolkit application is running, it is idle. It's +sitting in the event loop, waiting for some I/O to happen. The most important +kind of I/O we're waiting for is user input. So, within the event loop, we have +one file descriptor that represents the input device from where we receive key +presses. The details are a little different between operating systems, but it +comes down to a selector (like select or epoll) which waits for one or more +file descriptor. The event loop is then responsible for calling the appropriate +feedback when one of the file descriptors becomes ready. + +It is like that when the user presses a key: the input device becomes ready for +reading, and the appropriate callback is called. This is the `read_from_input` +function somewhere in `application.py`. It will read the input from the +:class:`~prompt_toolkit.input.Input` object, by calling +:meth:`~prompt_toolkit.input.Input.read_keys`. + + +Reading the user input +---------------------- + +The actual reading is also operating system dependent. For instance, on a Linux +machine with a vt100 terminal, we read the input from the pseudo terminal +device, by calling `os.read`. This however returns a sequence of bytes. There +are two difficulties: + +- The input could be UTF-8 encoded, and there is always the possibility that we + receive only a portion of a multi-byte character. +- vt100 key presses consist of multiple characters. For instance the "left + arrow" would generate something like ``\x1b[D``. It could be that when we + read this input stream, that at some point we only get the first part of such + a key press, and we have to wait for the rest to arrive. + +Both problems are implemented using state machines. + +- The UTF-8 problem is solved using `codecs.getincrementaldecoder`, which is an + object in which we can feed the incoming bytes, and it will only return the + complete UTF-8 characters that we have so far. The rest is buffered for the + next read operation. +- Vt100 parsing is solved by the + :class:`~prompt_toolkit.input.vt100_parser.Vt100Parser` state machine. The + state machine itself is implemented using a generator. We feed the incoming + characters to the generator, and it will call the appropriate callback for + key presses once they arrive. One thing here to keep in mind is that the + characters for some key presses are a prefix of other key presses, like for + instance, escape (``\x1b``) is a prefix of the left arrow key (``\x1b[D``). + So for those, we don't know what key is pressed until more data arrives or + when the input is flushed because of a timeout. + +For Windows systems, it's a little different. Here we use Win32 syscalls for +reading the console input. + + +Processing the key presses +-------------------------- + +The ``Key`` objects that we receive are then passed to the +:class:`~prompt_toolkit.key_binding.key_processor.KeyProcessor` for matching +against the currently registered and active key bindings. + +This is another state machine, because key bindings are linked to a sequence of +key presses. We cannot call the handler until all of these key presses arrive +and until we're sure that this combination is not a prefix of another +combination. For instance, sometimes people bind ``jj`` (a double ``j`` key +press) to ``esc`` in Vi mode. This is convenient, but we want to make sure that +pressing ``j`` once only, followed by a different key will still insert the +``j`` character as usual. + +Now, there are hundreds of key bindings in prompt_toolkit (in ptpython, right +now we have 585 bindings). This is mainly caused by the way that Vi key +bindings are generated. In order to make this efficient, we keep a cache of +handlers which match certain sequences of keys. + +Of course, key bindings also have filters attached for enabling/disabling them. +So, if at some point, we get a list of handlers from that cache, we still have +to discard the inactive bindings. Luckily, many bindings share exactly the same +filter, and we have to check every filter only once. + +:ref:`Read more about key bindings ...` + + +The key handlers +---------------- + +Once a key sequence is matched, the handler is called. This can do things like +text manipulation, changing the focus or anything else. + +After the handler is called, the user interface is invalidated and rendered +again. + + +Rendering the user interface +---------------------------- + +The rendering is pretty complex for several reasons: + +- We have to compute the dimensions of all user interface elements. Sometimes + they are given, but sometimes this requires calculating the size of + :class:`~prompt_toolkit.layout.UIControl` objects. +- It needs to be very efficient, because it's something that happens on every + single key stroke. +- We should output as little as possible on stdout in order to reduce latency + on slow network connections and older terminals. + + +Calculating the total UI height +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Unless the application is a full screen application, we have to know how much +vertical space is going to be consumed. The total available width is given, but +the vertical space is more dynamic. We do this by asking the root +:class:`~prompt_toolkit.layout.Container` object to calculate its preferred +height. If this is a :class:`~prompt_toolkit.layout.VSplit` or +:class:`~prompt_toolkit.layout.HSplit` then this involves recursively querying +the child objects for their preferred widths and heights and either summing it +up, or taking maximum values depending on the actual layout. +In the end, we get the preferred height, for which we make sure it's at least +the distance from the cursor position to the bottom of the screen. + + +Painting to the screen +^^^^^^^^^^^^^^^^^^^^^^ + +Then we create a :class:`~prompt_toolkit.layout.screen.Screen` object. This is +like a canvas on which user controls can paint their content. The +:meth:`~prompt_toolkit.layout.Container.write_to_screen` method of the root +`Container` is called with the screen dimensions. This will call recursively +:meth:`~prompt_toolkit.layout.Container.write_to_screen` methods of nested +child containers, each time passing smaller dimensions while we traverse what +is a tree of `Container` objects. + +The most inner containers are :class:`~prompt_toolkit.layout.Window` objects, +they will do the actual painting of the +:class:`~prompt_toolkit.layout.UIControl` to the screen. This involves line +wrapping the `UIControl`'s text and maybe scrolling the content horizontally or +vertically. + + +Rendering to stdout +^^^^^^^^^^^^^^^^^^^ + +Finally, when we have painted the screen, this needs to be rendered to stdout. +This is done by taking the difference of the previously rendered screen and the +new one. The algorithm that we have is heavily optimized to compute this +difference as quickly as possible, and call the appropriate output functions of +the :class:`~prompt_toolkit.output.Output` back-end. At the end, it will +position the cursor in the right place. diff --git a/docs/pages/advanced_topics/styling.rst b/docs/pages/advanced_topics/styling.rst new file mode 100644 index 0000000..55cf6ee --- /dev/null +++ b/docs/pages/advanced_topics/styling.rst @@ -0,0 +1,320 @@ +.. _styling: + +More about styling +================== + +This page will attempt to explain in more detail how to use styling in +prompt_toolkit. + +To some extent, it is very similar to how `Pygments `_ +styling works. + + +Style strings +------------- + +Many user interface controls, like :class:`~prompt_toolkit.layout.Window` +accept a ``style`` argument which can be used to pass the formatting as a +string. For instance, we can select a foreground color: + +- ``"fg:ansired"`` (ANSI color palette) +- ``"fg:ansiblue"`` (ANSI color palette) +- ``"fg:#ffaa33"`` (hexadecimal notation) +- ``"fg:darkred"`` (named color) + +Or a background color: + +- ``"bg:ansired"`` (ANSI color palette) +- ``"bg:#ffaa33"`` (hexadecimal notation) + +Or we can add one of the following flags: + +- ``"bold"`` +- ``"italic"`` +- ``"underline"`` +- ``"blink"`` +- ``"reverse"`` (reverse foreground and background on the terminal.) +- ``"hidden"`` + +Or their negative variants: + +- ``"nobold"`` +- ``"noitalic"`` +- ``"nounderline"`` +- ``"noblink"`` +- ``"noreverse"`` +- ``"nohidden"`` + +All of these formatting options can be combined as well: + +- ``"fg:ansiyellow bg:black bold underline"`` + +The style string can be given to any user control directly, or to a +:class:`~prompt_toolkit.layout.Container` object from where it will propagate +to all its children. A style defined by a parent user control can be overridden +by any of its children. The parent can for instance say ``style="bold +underline"`` where a child overrides this style partly by specifying +``style="nobold bg:ansired"``. + +.. note:: + + These styles are actually compatible with + `Pygments `_ styles, with additional support for + `reverse` and `blink`. Further, we ignore flags like `roman`, `sans`, + `mono` and `border`. + +The following ANSI colors are available (both for foreground and background): + +.. code:: + + # Low intensity, dark. (One or two components 0x80, the other 0x00.) + ansiblack, ansired, ansigreen, ansiyellow, ansiblue + ansimagenta, ansicyan, ansigray + + # High intensity, bright. + ansibrightblack, ansibrightred, ansibrightgreen, ansibrightyellow + ansibrightblue, ansibrightmagenta, ansibrightcyan, ansiwhite + +In order to know which styles are actually used in an application, it is +possible to call :meth:`~Application.get_used_style_strings`, when the +application is done. + + +Class names +----------- + +Like we do for web design, it is not a good habit to specify all styling +inline. Instead, we can attach class names to UI controls and have a style +sheet that refers to these class names. The +:class:`~prompt_toolkit.styles.Style` can be passed as an argument to the +:class:`~prompt_toolkit.application.Application`. + +.. code:: python + + from prompt_toolkit.layout import VSplit, Window + from prompt_toolkit.styles import Style + + layout = VSplit([ + Window(BufferControl(...), style='class:left'), + HSplit([ + Window(BufferControl(...), style='class:top'), + Window(BufferControl(...), style='class:bottom'), + ], style='class:right') + ]) + + style = Style([ + ('left', 'bg:ansired'), + ('top', 'fg:#00aaaa'), + ('bottom', 'underline bold'), + ]) + +It is possible to add multiple class names to an element. That way we'll +combine the styling for these class names. Multiple classes can be passed by +using a comma separated list, or by using the ``class:`` prefix twice. + +.. code:: python + + Window(BufferControl(...), style='class:left,bottom'), + Window(BufferControl(...), style='class:left class:bottom'), + +It is possible to combine class names and inline styling. The order in which +the class names and inline styling is specified determines the order of +priority. In the following example for instance, we'll take first the style of +the "header" class, and then override that with a red background color. + +.. code:: python + + Window(BufferControl(...), style='class:header bg:red'), + + +Dot notation in class names +--------------------------- + +The dot operator has a special meaning in a class name. If we write: +``style="class:a.b.c"``, then this will actually expand to the following: +``style="class:a class:a.b class:a.b.c"``. + +This is mainly added for `Pygments `_ lexers, which +specify "Tokens" like this, but it's useful in other situations as well. + + +Multiple classes in a style sheet +--------------------------------- + +A style sheet can be more complex as well. We can for instance specify two +class names. The following will underline the left part within the header, or +whatever has both the class "left" and the class "header" (the order doesn't +matter). + +.. code:: python + + style = Style([ + ('header left', 'underline'), + ]) + + +If you have a dotted class, then it's required to specify the whole path in the +style sheet (just typing ``c`` or ``b.c`` doesn't work if the class is +``a.b.c``): + +.. code:: python + + style = Style([ + ('a.b.c', 'underline'), + ]) + +It is possible to combine this: + +.. code:: python + + style = Style([ + ('header body left.text', 'underline'), + ]) + + +Evaluation order of rules in a style sheet +------------------------------------------ + +The style is determined as follows: + +- First, we concatenate all the style strings from the root control through all + the parents to the child in one big string. (Things at the right take + precedence anyway.) + + E.g: ``class:body bg:#aaaaaa #000000 class:header.focused class:left.text.highlighted underline`` + +- Then we go through this style from left to right, starting from the default + style. Inline styling is applied directly. + + If we come across a class name, then we generate all combinations of the + class names that we collected so far (this one and all class names to the + left), and for each combination which includes the new class name, we look + for matching rules in our style sheet. All these rules are then applied + (later rules have higher priority). + + If we find a dotted class name, this will be expanded in the individual names + (like ``class:left class:left.text class:left.text.highlighted``), and all + these are applied like any class names. + +- Then this final style is applied to this user interface element. + + +Using a dictionary as a style sheet +----------------------------------- + +The order of the rules in a style sheet is meaningful, so typically, we use a +list of tuples to specify the style. But is also possible to use a dictionary +as a style sheet. This makes sense for Python 3.6, where dictionaries remember +their ordering. An ``OrderedDict`` works as well. + +.. code:: python + + from prompt_toolkit.styles import Style + + style = Style.from_dict({ + 'header body left.text': 'underline', + }) + + +Loading a style from Pygments +----------------------------- + +`Pygments `_ has a slightly different notation for +specifying styles, because it maps styling to Pygments "Tokens". A Pygments +style can however be loaded and used as follows: + +.. code:: python + + from prompt_toolkit.styles.pygments import style_from_pygments_cls + from pygments.styles import get_style_by_name + + style = style_from_pygments_cls(get_style_by_name('monokai')) + + +Merging styles together +----------------------- + +Multiple :class:`~prompt_toolkit.styles.Style` objects can be merged together as +follows: + +.. code:: python + + from prompt_toolkit.styles import merge_styles + + style = merge_styles([ + style1, + style2, + style3 + ]) + + +Color depths +------------ + +There are four different levels of color depths available: + ++--------+-----------------+-----------------------------+---------------------------------+ +| 1 bit | Black and white | ``ColorDepth.DEPTH_1_BIT`` | ``ColorDepth.MONOCHROME`` | ++--------+-----------------+-----------------------------+---------------------------------+ +| 4 bit | ANSI colors | ``ColorDepth.DEPTH_4_BIT`` | ``ColorDepth.ANSI_COLORS_ONLY`` | ++--------+-----------------+-----------------------------+---------------------------------+ +| 8 bit | 256 colors | ``ColorDepth.DEPTH_8_BIT`` | ``ColorDepth.DEFAULT`` | ++--------+-----------------+-----------------------------+---------------------------------+ +| 24 bit | True colors | ``ColorDepth.DEPTH_24_BIT`` | ``ColorDepth.TRUE_COLOR`` | ++--------+-----------------+-----------------------------+---------------------------------+ + +By default, 256 colors are used, because this is what most terminals support +these days. If the ``TERM`` environment variable is set to ``linux`` or +``eterm-color``, then only ANSI colors are used, because of these terminals. The 24 +bit true color output needs to be enabled explicitly. When 4 bit color output +is chosen, all colors will be mapped to the closest ANSI color. + +Setting the default color depth for any prompt_toolkit application can be done +by setting the ``PROMPT_TOOLKIT_COLOR_DEPTH`` environment variable. You could +for instance copy the following into your `.bashrc` file. + +.. code:: shell + + # export PROMPT_TOOLKIT_COLOR_DEPTH=DEPTH_1_BIT + export PROMPT_TOOLKIT_COLOR_DEPTH=DEPTH_4_BIT + # export PROMPT_TOOLKIT_COLOR_DEPTH=DEPTH_8_BIT + # export PROMPT_TOOLKIT_COLOR_DEPTH=DEPTH_24_BIT + +An application can also decide to set the color depth manually by passing a +:class:`~prompt_toolkit.output.ColorDepth` value to the +:class:`~prompt_toolkit.application.Application` object: + +.. code:: python + + from prompt_toolkit.output.color_depth import ColorDepth + + app = Application( + color_depth=ColorDepth.ANSI_COLORS_ONLY, + # ... + ) + + +Style transformations +--------------------- + +Prompt_toolkit supports a way to apply certain transformations to the styles +near the end of the rendering pipeline. This can be used for instance to change +certain colors to improve the rendering in some terminals. + +One useful example is the +:class:`~prompt_toolkit.styles.AdjustBrightnessStyleTransformation` class, +which takes `min_brightness` and `max_brightness` as arguments which by default +have 0.0 and 1.0 as values. In the following code snippet, we increase the +minimum brightness to improve rendering on terminals with a dark background. + +.. code:: python + + from prompt_toolkit.styles import AdjustBrightnessStyleTransformation + + app = Application( + style_transformation=AdjustBrightnessStyleTransformation( + min_brightness=0.5, # Increase the minimum brightness. + max_brightness=1.0, + ) + # ... + ) diff --git a/docs/pages/advanced_topics/unit_testing.rst b/docs/pages/advanced_topics/unit_testing.rst new file mode 100644 index 0000000..2224bfc --- /dev/null +++ b/docs/pages/advanced_topics/unit_testing.rst @@ -0,0 +1,125 @@ +.. _unit_testing: + +Unit testing +============ + +Testing user interfaces is not always obvious. Here are a few tricks for +testing prompt_toolkit applications. + + +`PosixPipeInput` and `DummyOutput` +---------------------------------- + +During the creation of a prompt_toolkit +:class:`~prompt_toolkit.application.Application`, we can specify what input and +output device to be used. By default, these are output objects that correspond +with `sys.stdin` and `sys.stdout`. In unit tests however, we want to replace +these. + +- For the input, we want a "pipe input". This is an input device, in which we + can programmatically send some input. It can be created with + :func:`~prompt_toolkit.input.create_pipe_input`, and that return either a + :class:`~prompt_toolkit.input.posix_pipe.PosixPipeInput` or a + :class:`~prompt_toolkit.input.win32_pipe.Win32PipeInput` depending on the + platform. +- For the output, we want a :class:`~prompt_toolkit.output.DummyOutput`. This is + an output device that doesn't render anything. We don't want to render + anything to `sys.stdout` in the unit tests. + +.. note:: + + Typically, we don't want to test the bytes that are written to + `sys.stdout`, because these can change any time when the rendering + algorithm changes, and are not so meaningful anyway. Instead, we want to + test the return value from the + :class:`~prompt_toolkit.application.Application` or test how data + structures (like text buffers) change over time. + +So we programmatically feed some input to the input pipe, have the key +bindings process the input and then test what comes out of it. + +In the following example we use a +:class:`~prompt_toolkit.shortcuts.PromptSession`, but the same works for any +:class:`~prompt_toolkit.application.Application`. + +.. code:: python + + from prompt_toolkit.shortcuts import PromptSession + from prompt_toolkit.input import create_pipe_input + from prompt_toolkit.output import DummyOutput + + def test_prompt_session(): + with create_pipe_input() as inp: + inp.send_text("hello\n") + session = PromptSession( + input=inp, + output=DummyOutput(), + ) + + result = session.prompt() + + assert result == "hello" + +In the above example, don't forget to send the `\\n` character to accept the +prompt, otherwise the :class:`~prompt_toolkit.application.Application` will +wait forever for some more input to receive. + +Using an :class:`~prompt_toolkit.application.current.AppSession` +---------------------------------------------------------------- + +Sometimes it's not convenient to pass input or output objects to the +:class:`~prompt_toolkit.application.Application`, and in some situations it's +not even possible at all. +This happens when these parameters are not passed down the call stack, through +all function calls. + +An easy way to specify which input/output to use for all applications, is by +creating an :class:`~prompt_toolkit.application.current.AppSession` with this +input/output and running all code in that +:class:`~prompt_toolkit.application.current.AppSession`. This way, we don't +need to inject it into every :class:`~prompt_toolkit.application.Application` +or :func:`~prompt_toolkit.shortcuts.print_formatted_text` call. + +Here is an example where we use +:func:`~prompt_toolkit.application.create_app_session`: + +.. code:: python + + from prompt_toolkit.application import create_app_session + from prompt_toolkit.shortcuts import print_formatted_text + from prompt_toolkit.output import DummyOutput + + def test_something(): + with create_app_session(output=DummyOutput()): + ... + print_formatted_text('Hello world') + ... + +Pytest fixtures +--------------- + +In order to get rid of the boilerplate of creating the input, the +:class:`~prompt_toolkit.output.DummyOutput`, and the +:class:`~prompt_toolkit.application.current.AppSession`, we create a +single fixture that does it for every test. Something like this: + +.. code:: python + + import pytest + from prompt_toolkit.application import create_app_session + from prompt_toolkit.input import create_pipe_input + from prompt_toolkit.output import DummyOutput + + @pytest.fixture(autouse=True, scope="function") + def mock_input(): + with create_pipe_input() as pipe_input: + with create_app_session(input=pipe_input, output=DummyOutput()): + yield pipe_input + + +Type checking +------------- + +Prompt_toolkit 3.0 is fully type annotated. This means that if a +prompt_toolkit application is typed too, it can be verified with mypy. This is +complementary to unit tests, but also great for testing for correctness. diff --git a/docs/pages/asking_for_input.rst b/docs/pages/asking_for_input.rst new file mode 100644 index 0000000..20619ac --- /dev/null +++ b/docs/pages/asking_for_input.rst @@ -0,0 +1,1034 @@ +.. _asking_for_input: + +Asking for input (prompts) +========================== + +This page is about building prompts. Pieces of code that we can embed in a +program for asking the user for input. Even if you want to use `prompt_toolkit` +for building full screen terminal applications, it is probably still a good +idea to read this first, before heading to the :ref:`building full screen +applications ` page. + +In this page, we will cover autocompletion, syntax highlighting, key bindings, +and so on. + + +Hello world +----------- + +The following snippet is the most simple example, it uses the +:func:`~prompt_toolkit.shortcuts.prompt` function to ask the user for input +and returns the text. Just like ``(raw_)input``. + +.. code:: python + + from prompt_toolkit import prompt + + text = prompt('Give me some input: ') + print('You said: %s' % text) + +.. image:: ../images/hello-world-prompt.png + +What we get here is a simple prompt that supports the Emacs key bindings like +readline, but further nothing special. However, +:func:`~prompt_toolkit.shortcuts.prompt` has a lot of configuration options. +In the following sections, we will discover all these parameters. + + +The `PromptSession` object +-------------------------- + +Instead of calling the :func:`~prompt_toolkit.shortcuts.prompt` function, it's +also possible to create a :class:`~prompt_toolkit.shortcuts.PromptSession` +instance followed by calling its +:meth:`~prompt_toolkit.shortcuts.PromptSession.prompt` method for every input +call. This creates a kind of an input session. + +.. code:: python + + from prompt_toolkit import PromptSession + + # Create prompt object. + session = PromptSession() + + # Do multiple input calls. + text1 = session.prompt() + text2 = session.prompt() + +This has mainly two advantages: + +- The input history will be kept between consecutive + :meth:`~prompt_toolkit.shortcuts.PromptSession.prompt` calls. + +- The :func:`~prompt_toolkit.shortcuts.PromptSession` instance and its + :meth:`~prompt_toolkit.shortcuts.PromptSession.prompt` method take about the + same arguments, like all the options described below (highlighting, + completion, etc...). So if you want to ask for multiple inputs, but each + input call needs about the same arguments, they can be passed to the + :func:`~prompt_toolkit.shortcuts.PromptSession` instance as well, and they + can be overridden by passing values to the + :meth:`~prompt_toolkit.shortcuts.PromptSession.prompt` method. + + +Syntax highlighting +------------------- + +Adding syntax highlighting is as simple as adding a lexer. All of the `Pygments +`_ lexers can be used after wrapping them in a +:class:`~prompt_toolkit.lexers.PygmentsLexer`. It is also possible to create a +custom lexer by implementing the :class:`~prompt_toolkit.lexers.Lexer` abstract +base class. + +.. code:: python + + from pygments.lexers.html import HtmlLexer + from prompt_toolkit.shortcuts import prompt + from prompt_toolkit.lexers import PygmentsLexer + + text = prompt('Enter HTML: ', lexer=PygmentsLexer(HtmlLexer)) + print('You said: %s' % text) + +.. image:: ../images/html-input.png + +The default Pygments colorscheme is included as part of the default style in +prompt_toolkit. If you want to use another Pygments style along with the lexer, +you can do the following: + +.. code:: python + + from pygments.lexers.html import HtmlLexer + from pygments.styles import get_style_by_name + from prompt_toolkit.shortcuts import prompt + 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) + +We pass ``include_default_pygments_style=False``, because otherwise, both +styles will be merged, possibly giving slightly different colors in the outcome +for cases where where our custom Pygments style doesn't specify a color. + +.. _colors: + +Colors +------ + +The colors for syntax highlighting are defined by a +:class:`~prompt_toolkit.styles.Style` instance. By default, a neutral +built-in style is used, but any style instance can be passed to the +:func:`~prompt_toolkit.shortcuts.prompt` function. A simple way to create a +style, is by using the :meth:`~prompt_toolkit.styles.Style.from_dict` +function: + +.. code:: python + + from pygments.lexers.html import HtmlLexer + from prompt_toolkit.shortcuts import prompt + from prompt_toolkit.styles import Style + from prompt_toolkit.lexers import PygmentsLexer + + our_style = Style.from_dict({ + 'pygments.comment': '#888888 bold', + 'pygments.keyword': '#ff88ff bold', + }) + + text = prompt('Enter HTML: ', lexer=PygmentsLexer(HtmlLexer), + style=our_style) + + +The style dictionary is very similar to the Pygments ``styles`` dictionary, +with a few differences: + +- The `roman`, `sans`, `mono` and `border` options are ignored. +- The style has a few additions: ``blink``, ``noblink``, ``reverse`` and ``noreverse``. +- Colors can be in the ``#ff0000`` format, but they can be one of the built-in + ANSI color names as well. In that case, they map directly to the 16 color + palette of the terminal. + +:ref:`Read more about styling `. + + +Using a Pygments style +^^^^^^^^^^^^^^^^^^^^^^ + +All Pygments style classes can be used as well, when they are wrapped through +:func:`~prompt_toolkit.styles.style_from_pygments_cls`. + +Suppose we'd like to use a Pygments style, for instance +``pygments.styles.tango.TangoStyle``, that is possible like this: + +.. code:: python + + from prompt_toolkit.shortcuts import prompt + from prompt_toolkit.styles import style_from_pygments_cls + 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) + +Creating a custom style could be done like this: + +.. code:: python + + from prompt_toolkit.shortcuts import prompt + from prompt_toolkit.styles import Style, style_from_pygments_cls, merge_styles + from prompt_toolkit.lexers import PygmentsLexer + + from pygments.styles.tango import TangoStyle + from pygments.lexers.html import HtmlLexer + + our_style = merge_styles([ + style_from_pygments_cls(TangoStyle), + Style.from_dict({ + 'pygments.comment': '#888888 bold', + 'pygments.keyword': '#ff88ff bold', + }) + ]) + + text = prompt('Enter HTML: ', lexer=PygmentsLexer(HtmlLexer), + style=our_style) + + +Coloring the prompt itself +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +It is possible to add some colors to the prompt itself. For this, we need to +build some :ref:`formatted text `. One way of doing this is by +creating a list of style/text tuples. In the following example, we use class +names to refer to the style. + +.. code:: python + + from prompt_toolkit.shortcuts import prompt + from prompt_toolkit.styles import Style + + style = Style.from_dict({ + # User input (default text). + '': '#ff0066', + + # Prompt. + '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', '# '), + ] + + text = prompt(message, style=style) + +.. image:: ../images/colored-prompt.png + +The `message` can be any kind of formatted text, as discussed :ref:`here +`. It can also be a callable that returns some formatted text. + +By default, colors are taken from the 256 color palette. If you want to have +24bit true color, this is possible by adding the +``color_depth=ColorDepth.TRUE_COLOR`` option to the +:func:`~prompt_toolkit.shortcuts.prompt.prompt` function. + +.. code:: python + + from prompt_toolkit.output import ColorDepth + + text = prompt(message, style=style, color_depth=ColorDepth.TRUE_COLOR) + + +Autocompletion +-------------- + +Autocompletion can be added by passing a ``completer`` parameter. This should +be an instance of the :class:`~prompt_toolkit.completion.Completer` abstract +base class. :class:`~prompt_toolkit.completion.WordCompleter` is an example of +a completer that implements that interface. + +.. code:: python + + 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) + +:class:`~prompt_toolkit.completion.WordCompleter` is a simple completer that +completes the last word before the cursor with any of the given words. + +.. image:: ../images/html-completion.png + +.. note:: + + Note that in prompt_toolkit 2.0, the auto completion became synchronous. This + means that if it takes a long time to compute the completions, that this + will block the event loop and the input processing. + + For heavy completion algorithms, it is recommended to wrap the completer in + a :class:`~prompt_toolkit.completion.ThreadedCompleter` in order to run it + in a background thread. + + +Nested completion +^^^^^^^^^^^^^^^^^ + +Sometimes you have a command line interface where the completion depends on the +previous words from the input. Examples are the CLIs from routers and switches. +A simple :class:`~prompt_toolkit.completion.WordCompleter` is not enough in +that case. We want to to be able to define completions at multiple hierarchical +levels. :class:`~prompt_toolkit.completion.NestedCompleter` solves this issue: + +.. code:: python + + from prompt_toolkit import prompt + from prompt_toolkit.completion import NestedCompleter + + completer = NestedCompleter.from_nested_dict({ + 'show': { + 'version': None, + 'clock': None, + 'ip': { + 'interface': {'brief'} + } + }, + 'exit': None, + }) + + text = prompt('# ', completer=completer) + print('You said: %s' % 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 +be ``None``, it can also be replaced with a set. + + +A custom completer +^^^^^^^^^^^^^^^^^^ + +For more complex examples, it makes sense to create a custom completer. For +instance: + +.. code:: python + + from prompt_toolkit import prompt + from prompt_toolkit.completion import Completer, Completion + + class MyCustomCompleter(Completer): + def get_completions(self, document, complete_event): + yield Completion('completion', start_position=0) + + text = prompt('> ', completer=MyCustomCompleter()) + +A :class:`~prompt_toolkit.completion.Completer` class has to implement a +generator named :meth:`~prompt_toolkit.completion.Completer.get_completions` +that takes a :class:`~prompt_toolkit.document.Document` and yields the current +:class:`~prompt_toolkit.completion.Completion` instances. Each completion +contains a portion of text, and a position. + +The position is used for fixing text before the cursor. Pressing the tab key +could for instance turn parts of the input from lowercase to uppercase. This +makes sense for a case insensitive completer. Or in case of a fuzzy completion, +it could fix typos. When ``start_position`` is something negative, this amount +of characters will be deleted and replaced. + + +Styling individual completions +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Each completion can provide a custom style, which is used when it is rendered +in the completion menu or toolbar. This is possible by passing a style to each +:class:`~prompt_toolkit.completion.Completion` instance. + +.. code:: python + + from prompt_toolkit.completion import Completer, Completion + + 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') + + # Underline completion. + 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') + +The "colorful-prompts.py" example uses completion styling: + +.. image:: ../images/colorful-completions.png + +Finally, it is possible to pass :ref:`formatted text <formatted_text>` for the +``display`` attribute of a :class:`~prompt_toolkit.completion.Completion`. This +provides all the freedom you need to display the text in any possible way. It +can also be combined with the ``style`` attribute. For instance: + +.. code:: python + + + from prompt_toolkit.completion import Completer, Completion + from prompt_toolkit.formatted_text import HTML + + 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') + + +Fuzzy completion +^^^^^^^^^^^^^^^^ + +If one possible completions is "django_migrations", a fuzzy completer would +allow you to get this by typing "djm" only, a subset of characters for this +string. + +Prompt_toolkit ships with a :class:`~prompt_toolkit.completion.FuzzyCompleter` +and :class:`~prompt_toolkit.completion.FuzzyWordCompleter` class. These provide +the means for doing this kind of "fuzzy completion". The first one can take any +completer instance and wrap it so that it becomes a fuzzy completer. The second +one behaves like a :class:`~prompt_toolkit.completion.WordCompleter` wrapped +into a :class:`~prompt_toolkit.completion.FuzzyCompleter`. + + +Complete while typing +^^^^^^^^^^^^^^^^^^^^^ + +Autcompletions can be generated automatically while typing or when the user +presses the tab key. This can be configured with the ``complete_while_typing`` +option: + +.. code:: python + + 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 +otherwise. So, make sure to disable history search for this. + + +Asynchronous completion +^^^^^^^^^^^^^^^^^^^^^^^ + +When generating the completions takes a lot of time, it's better to do this in +a background thread. This is possible by wrapping the completer in a +:class:`~prompt_toolkit.completion.ThreadedCompleter`, but also by passing the +`complete_in_thread=True` argument. + + +.. code:: python + + text = prompt('> ', completer=MyCustomCompleter(), complete_in_thread=True) + + +Input validation +---------------- + +A prompt can have a validator attached. This is some code that will check +whether the given input is acceptable and it will only return it if that's the +case. Otherwise it will show an error message and move the cursor to a given +position. + +A validator should implements the :class:`~prompt_toolkit.validation.Validator` +abstract base class. This requires only one method, named ``validate`` that +takes a :class:`~prompt_toolkit.document.Document` as input and raises +:class:`~prompt_toolkit.validation.ValidationError` when the validation fails. + +.. code:: python + + from prompt_toolkit.validation import Validator, ValidationError + from prompt_toolkit import prompt + + class NumberValidator(Validator): + def validate(self, document): + text = document.text + + if text and not text.isdigit(): + i = 0 + + # Get index of first non numeric character. + # We want to move the cursor here. + for i, c in enumerate(text): + if not c.isdigit(): + break + + 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) + +.. image:: ../images/number-validator.png + +By default, the input is validated in real-time while the user is typing, but +prompt_toolkit can also validate after the user presses the enter key: + +.. code:: python + + 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 +in a :class:`~prompt_toolkit.validation.ThreadedValidator`. + +Validator from a callable +^^^^^^^^^^^^^^^^^^^^^^^^^ + +Instead of implementing the :class:`~prompt_toolkit.validation.Validator` +abstract base class, it is also possible to start from a simple function and +use the :meth:`~prompt_toolkit.validation.Validator.from_callable` classmethod. +This is easier and sufficient for probably 90% of the validators. It looks as +follows: + +.. code:: python + + from prompt_toolkit.validation import Validator + from prompt_toolkit import prompt + + def is_number(text): + return text.isdigit() + + validator = Validator.from_callable( + is_number, + 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) + +We define a function that takes a string, and tells whether it's valid input or +not by returning a boolean. +:meth:`~prompt_toolkit.validation.Validator.from_callable` turns that into a +:class:`~prompt_toolkit.validation.Validator` instance. Notice that setting the +cursor position is not possible this way. + + +History +------- + +A :class:`~prompt_toolkit.history.History` object keeps track of all the +previously entered strings, so that the up-arrow can reveal previously entered +items. + +The recommended way is to use a +:class:`~prompt_toolkit.shortcuts.PromptSession`, which uses an +:class:`~prompt_toolkit.history.InMemoryHistory` for the entire session by +default. The following example has a history out of the box: + +.. code:: python + + from prompt_toolkit import PromptSession + + session = PromptSession() + + while True: + session.prompt() + +To persist a history to disk, use a :class:`~prompt_toolkit.history.FileHistory` +instead of the default +:class:`~prompt_toolkit.history.InMemoryHistory`. This history object can be +passed either to a :class:`~prompt_toolkit.shortcuts.PromptSession` or to the +:meth:`~prompt_toolkit.shortcuts.prompt` function. For instance: + +.. code:: python + + from prompt_toolkit import PromptSession + from prompt_toolkit.history import FileHistory + + session = PromptSession(history=FileHistory('~/.myhistory')) + + while True: + session.prompt() + + +Auto suggestion +--------------- + +Auto suggestion is a way to propose some input completions to the user like the +`fish shell <http://fishshell.com/>`_. + +Usually, the input is compared to the history and when there is another entry +starting with the given text, the completion will be shown as gray text behind +the current input. Pressing the right arrow :kbd:`→` or :kbd:`c-e` will insert +this suggestion, :kbd:`alt-f` will insert the first word of the suggestion. + +.. note:: + + When suggestions are based on the history, don't forget to share one + :class:`~prompt_toolkit.history.History` object between consecutive + :func:`~prompt_toolkit.shortcuts.prompt` calls. Using a + :class:`~prompt_toolkit.shortcuts.PromptSession` does this for you. + +Example: + +.. code:: python + + from prompt_toolkit import PromptSession + from prompt_toolkit.history import InMemoryHistory + from prompt_toolkit.auto_suggest import AutoSuggestFromHistory + + session = PromptSession() + + while True: + text = session.prompt('> ', auto_suggest=AutoSuggestFromHistory()) + print('You said: %s' % text) + +.. image:: ../images/auto-suggestion.png + +A suggestion does not have to come from the history. Any implementation of the +:class:`~prompt_toolkit.auto_suggest.AutoSuggest` abstract base class can be +passed as an argument. + + +Adding a bottom toolbar +----------------------- + +Adding a bottom toolbar is as easy as passing a ``bottom_toolbar`` argument to +:func:`~prompt_toolkit.shortcuts.prompt`. This argument be either plain text, +:ref:`formatted text <formatted_text>` or a callable that returns plain or +formatted text. + +When a function is given, it will be called every time the prompt is rendered, +so the bottom toolbar can be used to display dynamic information. + +The toolbar is always erased when the prompt returns. +Here we have an example of a callable that returns an +:class:`~prompt_toolkit.formatted_text.HTML` object. By default, the toolbar +has the **reversed style**, which is why we are setting the background instead +of the foreground. + +.. code:: python + + from prompt_toolkit import prompt + from prompt_toolkit.formatted_text import HTML + + def bottom_toolbar(): + return HTML('This is a <b><style bg="ansired">Toolbar</style></b>!') + + text = prompt('> ', bottom_toolbar=bottom_toolbar) + print('You said: %s' % text) + +.. image:: ../images/bottom-toolbar.png + +Similar, we could use a list of style/text tuples. + +.. code:: python + + from prompt_toolkit import prompt + from prompt_toolkit.styles import Style + + def bottom_toolbar(): + return [('class:bottom-toolbar', ' This is a toolbar. ')] + + style = Style.from_dict({ + 'bottom-toolbar': '#ffffff bg:#333333', + }) + + text = prompt('> ', bottom_toolbar=bottom_toolbar, style=style) + print('You said: %s' % text) + +The default class name is ``bottom-toolbar`` and that will also be used to fill +the background of the toolbar. + + +Adding a right prompt +--------------------- + +The :func:`~prompt_toolkit.shortcuts.prompt` function has out of the box +support for right prompts as well. People familiar to ZSH could recognize this +as the `RPROMPT` option. + +So, similar to adding a bottom toolbar, we can pass an ``rprompt`` argument. +This can be either plain text, :ref:`formatted text <formatted_text>` or a +callable which returns either. + +.. code:: python + + from prompt_toolkit import prompt + from prompt_toolkit.styles import Style + + example_style = Style.from_dict({ + 'rprompt': 'bg:#ff0066 #ffffff', + }) + + def get_rprompt(): + return '<rprompt>' + + answer = prompt('> ', rprompt=get_rprompt, style=example_style) + +.. image:: ../images/rprompt.png + +The ``get_rprompt`` function can return any kind of formatted text such as +:class:`~prompt_toolkit.formatted_text.HTML`. it is also possible to pass text +directly to the ``rprompt`` argument of the +:func:`~prompt_toolkit.shortcuts.prompt` function. It does not have to be a +callable. + + +Vi input mode +------------- + +Prompt-toolkit supports both Emacs and Vi key bindings, similar to Readline. +The :func:`~prompt_toolkit.shortcuts.prompt` function will use Emacs bindings by +default. This is done because on most operating systems, also the Bash shell +uses Emacs bindings by default, and that is more intuitive. If however, Vi +binding are required, just pass ``vi_mode=True``. + +.. code:: python + + from prompt_toolkit import prompt + + prompt('> ', vi_mode=True) + + +Adding custom key bindings +-------------------------- + +By default, every prompt already has a set of key bindings which implements the +usual Vi or Emacs behavior. We can extend this by passing another +:class:`~prompt_toolkit.key_binding.KeyBindings` instance to the +``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. + +.. code:: python + + from prompt_toolkit import prompt + from prompt_toolkit.application import run_in_terminal + from prompt_toolkit.key_binding import KeyBindings + + bindings = KeyBindings() + + @bindings.add('c-t') + def _(event): + " Say 'hello' when `c-t` is pressed. " + def print_hello(): + print('hello world') + run_in_terminal(print_hello) + + @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) + + +Note that we use +:meth:`~prompt_toolkit.application.run_in_terminal` for the first key binding. +This ensures that the output of the print-statement and the prompt don't mix +up. If the key bindings doesn't print anything, then it can be handled directly +without nesting functions. + + +Enable key bindings according to a condition +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Often, some key bindings can be enabled or disabled according to a certain +condition. For instance, the Emacs and Vi bindings will never be active at the +same time, but it is possible to switch between Emacs and Vi bindings at run +time. + +In order to enable a key binding according to a certain condition, we have to +pass it a :class:`~prompt_toolkit.filters.Filter`, usually a +:class:`~prompt_toolkit.filters.Condition` instance. (:ref:`Read more about +filters <filters>`.) + +.. code:: python + + from prompt_toolkit import prompt + from prompt_toolkit.filters import Condition + from prompt_toolkit.key_binding import KeyBindings + + bindings = KeyBindings() + + @Condition + def is_active(): + " Only activate key binding on the second half of each minute. " + return datetime.datetime.now().second > 30 + + @bindings.add('c-t', filter=is_active) + def _(event): + # ... + pass + + prompt('> ', key_bindings=bindings) + + +Dynamically switch between Emacs and Vi mode +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The :class:`~prompt_toolkit.application.Application` has an ``editing_mode`` +attribute. We can change the key bindings by changing this attribute from +``EditingMode.VI`` to ``EditingMode.EMACS``. + +.. code:: python + + from prompt_toolkit import prompt + from prompt_toolkit.application.current import get_app + from prompt_toolkit.enums import EditingMode + from prompt_toolkit.key_binding import KeyBindings + + def run(): + # Create a set of key bindings. + bindings = KeyBindings() + + # Add an additional key binding for toggling this flag. + @bindings.add('f4') + def _(event): + " Toggle between Emacs and Vi mode. " + app = event.app + + if app.editing_mode == EditingMode.VI: + app.editing_mode = EditingMode.EMACS + else: + app.editing_mode = EditingMode.VI + + # 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' + return [ + ('class:toolbar', ' [F4] %s ' % text) + ] + + prompt('> ', key_bindings=bindings, bottom_toolbar=bottom_toolbar) + + run() + +:ref:`Read more about key bindings ...<key_bindings>` + +Using control-space for completion +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +An popular short cut that people sometimes use it to use control-space for +opening the autocompletion menu instead of the tab key. This can be done with +the following key binding. + +.. code:: python + + kb = KeyBindings() + + @kb.add('c-space') + def _(event): + " Initialize autocompletion, or select the next completion. " + buff = event.app.current_buffer + if buff.complete_state: + buff.complete_next() + else: + buff.start_completion(select_first=False) + + +Other prompt options +-------------------- + +Multiline input +^^^^^^^^^^^^^^^ + +Reading multiline input is as easy as passing the ``multiline=True`` parameter. + +.. code:: python + + from prompt_toolkit import prompt + + 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 +:kbd:`Meta+Enter` in order to accept the input. (Or :kbd:`Escape` followed by +:kbd:`Enter`.) + +It is possible to specify a continuation prompt. This works by passing a +``prompt_continuation`` callable to :func:`~prompt_toolkit.shortcuts.prompt`. +This function is supposed to return :ref:`formatted text <formatted_text>`, or +a list of ``(style, text)`` tuples. The width of the returned text should not +exceed the given width. (The width of the prompt margin is defined by the +prompt.) + +.. code:: python + + from prompt_toolkit import prompt + + def prompt_continuation(width, line_number, is_soft_wrap): + return '.' * width + # Or: return [('', '.' * width)] + + prompt('multiline input> ', multiline=True, + prompt_continuation=prompt_continuation) + +.. image:: ../images/multiline-input.png + + +Passing a default +^^^^^^^^^^^^^^^^^ + +A default value can be given: + +.. code:: python + + from prompt_toolkit import prompt + import getpass + + prompt('What is your name: ', default='%s' % getpass.getuser()) + + +Mouse support +^^^^^^^^^^^^^ + +There is limited mouse support for positioning the cursor, for scrolling (in +case of large multiline inputs) and for clicking in the autocompletion menu. + +Enabling can be done by passing the ``mouse_support=True`` option. + +.. code:: python + + from prompt_toolkit import prompt + + prompt('What is your name: ', mouse_support=True) + + +Line wrapping +^^^^^^^^^^^^^ + +Line wrapping is enabled by default. This is what most people are used to and +this is what GNU Readline does. When it is disabled, the input string will +scroll horizontally. + +.. code:: python + + from prompt_toolkit import prompt + + prompt('What is your name: ', wrap_lines=False) + + +Password input +^^^^^^^^^^^^^^ + +When the ``is_password=True`` flag has been given, the input is replaced by +asterisks (``*`` characters). + +.. code:: python + + from prompt_toolkit import prompt + + prompt('Enter password: ', is_password=True) + + +Cursor shapes +------------- + +Many terminals support displaying different types of cursor shapes. The most +common are block, beam or underscore. Either blinking or not. It is possible to +decide which cursor to display while asking for input, or in case of Vi input +mode, have a modal prompt for which its cursor shape changes according to the +input mode. + +.. code:: python + + from prompt_toolkit import prompt + 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 in an `asyncio` application +---------------------------------- + +.. note:: + + New in prompt_toolkit 3.0. (In prompt_toolkit 2.0 this was possible using a + work-around). + +For `asyncio <https://docs.python.org/3/library/asyncio.html>`_ applications, +it's very important to never block the eventloop. However, +:func:`~prompt_toolkit.shortcuts.prompt` is blocking, and calling this would +freeze the whole application. Asyncio actually won't even allow us to run that +function within a coroutine. + +The answer is to call +:meth:`~prompt_toolkit.shortcuts.PromptSession.prompt_async` instead of +:meth:`~prompt_toolkit.shortcuts.PromptSession.prompt`. The async variation +returns a coroutines and is awaitable. + +.. code:: python + + from prompt_toolkit import PromptSession + from prompt_toolkit.patch_stdout import patch_stdout + + async def my_coroutine(): + session = PromptSession() + while True: + with patch_stdout(): + result = await session.prompt_async('Say something: ') + print('You said: %s' % result) + +The :func:`~prompt_toolkit.patch_stdout.patch_stdout` context manager is +optional, but it's recommended, because other coroutines could print to stdout. +This ensures that other output won't destroy the prompt. + + +Reading keys from stdin, one key at a time, but without a prompt +---------------------------------------------------------------- + +Suppose that you want to use prompt_toolkit to read the keys from stdin, one +key at a time, but not render a prompt to the output, that is also possible: + +.. code:: python + + import asyncio + + from prompt_toolkit.input import create_input + from prompt_toolkit.keys import Keys + + + async def main() -> None: + done = asyncio.Event() + input = create_input() + + def keys_ready(): + for key_press in input.read_keys(): + print(key_press) + + if key_press.key == Keys.ControlC: + done.set() + + with input.raw_mode(): + with input.attach(keys_ready): + await done.wait() + + + if __name__ == "__main__": + asyncio.run(main()) + +The above snippet will print the `KeyPress` object whenever a key is pressed. +This is also cross platform, and should work on Windows. diff --git a/docs/pages/dialogs.rst b/docs/pages/dialogs.rst new file mode 100644 index 0000000..e171995 --- /dev/null +++ b/docs/pages/dialogs.rst @@ -0,0 +1,270 @@ +.. _dialogs: + +Dialogs +======= + +Prompt_toolkit ships with a high level API for displaying dialogs, similar to +the Whiptail program, but in pure Python. + + +Message box +----------- + +Use the :func:`~prompt_toolkit.shortcuts.message_dialog` function to display a +simple message box. For instance: + +.. code:: python + + from prompt_toolkit.shortcuts import message_dialog + + message_dialog( + title='Example dialog window', + text='Do you want to continue?\nPress ENTER to quit.').run() + +.. image:: ../images/dialogs/messagebox.png + + +Input box +--------- + +The :func:`~prompt_toolkit.shortcuts.input_dialog` function can display an +input box. It will return the user input as a string. + +.. code:: python + + from prompt_toolkit.shortcuts import input_dialog + + text = input_dialog( + title='Input dialog example', + text='Please type your name:').run() + +.. image:: ../images/dialogs/inputbox.png + + +The ``password=True`` option can be passed to the +:func:`~prompt_toolkit.shortcuts.input_dialog` function to turn this into a +password input box. + + +Yes/No confirmation dialog +-------------------------- + +The :func:`~prompt_toolkit.shortcuts.yes_no_dialog` function displays a yes/no +confirmation dialog. It will return a boolean according to the selection. + +.. code:: python + + from prompt_toolkit.shortcuts import yes_no_dialog + + result = yes_no_dialog( + title='Yes/No dialog example', + text='Do you want to confirm?').run() + +.. image:: ../images/dialogs/confirm.png + + +Button dialog +------------- + +The :func:`~prompt_toolkit.shortcuts.button_dialog` function displays a dialog +with choices offered as buttons. Buttons are indicated as a list of tuples, +each providing the label (first) and return value if clicked (second). + +.. code:: python + + from prompt_toolkit.shortcuts import button_dialog + + result = button_dialog( + title='Button dialog example', + text='Do you want to confirm?', + buttons=[ + ('Yes', True), + ('No', False), + ('Maybe...', None) + ], + ).run() + +.. image:: ../images/dialogs/button.png + + +Radio list dialog +----------------- + +The :func:`~prompt_toolkit.shortcuts.radiolist_dialog` function displays a dialog +with choices offered as a radio list. The values are provided as a list of tuples, +each providing the return value (first element) and the displayed value (second element). + +.. code:: python + + from prompt_toolkit.shortcuts import radiolist_dialog + + result = radiolist_dialog( + title="RadioList dialog", + text="Which breakfast would you like ?", + values=[ + ("breakfast1", "Eggs and beacon"), + ("breakfast2", "French breakfast"), + ("breakfast3", "Equestrian breakfast") + ] + ).run() + + +Checkbox list dialog +-------------------- + +The :func:`~prompt_toolkit.shortcuts.checkboxlist_dialog` has the same usage and purpose than the Radiolist dialog, but allows several values to be selected and therefore returned. + +.. code:: python + + from prompt_toolkit.shortcuts import checkboxlist_dialog + + results_array = checkboxlist_dialog( + title="CheckboxList dialog", + text="What would you like in your breakfast ?", + values=[ + ("eggs", "Eggs"), + ("bacon", "Bacon"), + ("croissants", "20 Croissants"), + ("daily", "The breakfast of the day") + ] + ).run() + + +Styling of dialogs +------------------ + +A custom :class:`~prompt_toolkit.styles.Style` instance can be passed to all +dialogs to override the default style. Also, text can be styled by passing an +:class:`~prompt_toolkit.formatted_text.HTML` object. + + +.. code:: python + + from prompt_toolkit.formatted_text import HTML + from prompt_toolkit.shortcuts import message_dialog + from prompt_toolkit.styles import Style + + example_style = Style.from_dict({ + 'dialog': 'bg:#88ff88', + 'dialog frame.label': 'bg:#ffffff #000000', + 'dialog.body': 'bg:#000000 #00ff00', + 'dialog shadow': 'bg:#00aa00', + }) + + message_dialog( + title=HTML('<style bg="blue" fg="white">Styled</style> ' + '<style fg="ansired">dialog</style> window'), + text='Do you want to continue?\nPress ENTER to quit.', + style=example_style).run() + +.. image:: ../images/dialogs/styled.png + +Styling reference sheet +----------------------- + +In reality, the shortcut commands presented above build a full-screen frame by using a list of components. The two tables below allow you to get the classnames available for each shortcut, therefore you will be able to provide a custom style for every element that is displayed, using the method provided above. + +.. note:: All the shortcuts use the ``Dialog`` component, therefore it isn't specified explicitly below. + ++--------------------------+-------------------------+ +| Shortcut | Components used | ++==========================+=========================+ +| ``yes_no_dialog`` | - ``Label`` | +| | - ``Button`` (x2) | ++--------------------------+-------------------------+ +| ``button_dialog`` | - ``Label`` | +| | - ``Button`` | ++--------------------------+-------------------------+ +| ``input_dialog`` | - ``TextArea`` | +| | - ``Button`` (x2) | ++--------------------------+-------------------------+ +| ``message_dialog`` | - ``Label`` | +| | - ``Button`` | ++--------------------------+-------------------------+ +| ``radiolist_dialog`` | - ``Label`` | +| | - ``RadioList`` | +| | - ``Button`` (x2) | ++--------------------------+-------------------------+ +| ``checkboxlist_dialog`` | - ``Label`` | +| | - ``CheckboxList`` | +| | - ``Button`` (x2) | ++--------------------------+-------------------------+ +| ``progress_dialog`` | - ``Label`` | +| | - ``TextArea`` (locked) | +| | - ``ProgressBar`` | ++--------------------------+-------------------------+ + ++----------------+-----------------------------+ +| Components | Available classnames | ++================+=============================+ +| Dialog | - ``dialog`` | +| | - ``dialog.body`` | ++----------------+-----------------------------+ +| TextArea | - ``text-area`` | +| | - ``text-area.prompt`` | ++----------------+-----------------------------+ +| Label | - ``label`` | ++----------------+-----------------------------+ +| Button | - ``button`` | +| | - ``button.focused`` | +| | - ``button.arrow`` | +| | - ``button.text`` | ++----------------+-----------------------------+ +| Frame | - ``frame`` | +| | - ``frame.border`` | +| | - ``frame.label`` | ++----------------+-----------------------------+ +| Shadow | - ``shadow`` | ++----------------+-----------------------------+ +| RadioList | - ``radio-list`` | +| | - ``radio`` | +| | - ``radio-checked`` | +| | - ``radio-selected`` | ++----------------+-----------------------------+ +| CheckboxList | - ``checkbox-list`` | +| | - ``checkbox`` | +| | - ``checkbox-checked`` | +| | - ``checkbox-selected`` | ++----------------+-----------------------------+ +| VerticalLine | - ``line`` | +| | - ``vertical-line`` | ++----------------+-----------------------------+ +| HorizontalLine | - ``line`` | +| | - ``horizontal-line`` | ++----------------+-----------------------------+ +| ProgressBar | - ``progress-bar`` | +| | - ``progress-bar.used`` | ++----------------+-----------------------------+ + +Example +_______ + +Let's customize the example of the ``checkboxlist_dialog``. + +It uses 2 ``Button``, a ``CheckboxList`` and a ``Label``, packed inside a ``Dialog``. +Therefore we can customize each of these elements separately, using for instance: + +.. code:: python + + from prompt_toolkit.shortcuts import checkboxlist_dialog + from prompt_toolkit.styles import Style + + results = checkboxlist_dialog( + title="CheckboxList dialog", + text="What would you like in your breakfast ?", + values=[ + ("eggs", "Eggs"), + ("bacon", "Bacon"), + ("croissants", "20 Croissants"), + ("daily", "The breakfast of the day") + ], + style=Style.from_dict({ + 'dialog': 'bg:#cdbbb3', + 'button': 'bg:#bf99a4', + 'checkbox': '#e8612c', + 'dialog.body': 'bg:#a9cfd0', + 'dialog shadow': 'bg:#c98982', + 'frame.label': '#fcaca3', + 'dialog.body label': '#fd8bb6', + }) + ).run() diff --git a/docs/pages/full_screen_apps.rst b/docs/pages/full_screen_apps.rst new file mode 100644 index 0000000..805c8c7 --- /dev/null +++ b/docs/pages/full_screen_apps.rst @@ -0,0 +1,422 @@ +.. _full_screen_applications: + +Building full screen applications +================================= + +`prompt_toolkit` can be used to create complex full screen terminal +applications. Typically, an application consists of a layout (to describe the +graphical part) and a set of key bindings. + +The sections below describe the components required for full screen +applications (or custom, non full screen applications), and how to assemble +them together. + +Before going through this page, it could be helpful to go through :ref:`asking +for input <asking_for_input>` (prompts) first. Many things that apply to an +input prompt, like styling, key bindings and so on, also apply to full screen +applications. + +.. note:: + + Also remember that the ``examples`` directory of the prompt_toolkit + repository contains plenty of examples. Each example is supposed to explain + one idea. So, this as well should help you get started. + + Don't hesitate to open a GitHub issue if you feel that a certain example is + missing. + + +A simple application +-------------------- + +Every prompt_toolkit application is an instance of an +:class:`~prompt_toolkit.application.Application` object. The simplest full +screen example would look like this: + +.. code:: python + + from prompt_toolkit import Application + + app = Application(full_screen=True) + app.run() + +This will display a dummy application that says "No layout specified. Press +ENTER to quit.". + +.. note:: + + If we wouldn't set the ``full_screen`` option, the application would + not run in the alternate screen buffer, and only consume the least + amount of space required for the layout. + +An application consists of several components. The most important are: + +- I/O objects: the input and output device. +- The layout: this defines the graphical structure of the application. For + instance, a text box on the left side, and a button on the right side. + You can also think of the layout as a collection of 'widgets'. +- A style: this defines what colors and underline/bold/italic styles are used + everywhere. +- A set of key bindings. + +We will discuss all of these in more detail below. + + +I/O objects +----------- + +Every :class:`~prompt_toolkit.application.Application` instance requires an I/O +object for input and output: + + - An :class:`~prompt_toolkit.input.Input` instance, which is an abstraction + of the input stream (stdin). + - An :class:`~prompt_toolkit.output.Output` instance, which is an + abstraction of the output stream, and is called by the renderer. + +Both are optional and normally not needed to pass explicitly. Usually, the +default works fine. + +There is a third I/O object which is also required by the application, but not +passed inside. This is the event loop, an +:class:`~prompt_toolkit.eventloop` instance. This is basically a +while-true loop that waits for user input, and when it receives something (like +a key press), it will send that to the the appropriate handler, like for +instance, a key binding. + +When :func:`~prompt_toolkit.application.Application.run()` is called, the event +loop will run until the application is done. An application will quit when +:func:`~prompt_toolkit.application.Application.exit()` is called. + + +The layout +---------- + +A layered layout architecture +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +There are several ways to create a prompt_toolkit layout, depending on how +customizable you want things to be. In fact, there are several layers of +abstraction. + +- The most low-level way of creating a layout is by combining + :class:`~prompt_toolkit.layout.Container` and + :class:`~prompt_toolkit.layout.UIControl` objects. + + Examples of :class:`~prompt_toolkit.layout.Container` objects are + :class:`~prompt_toolkit.layout.VSplit` (vertical split), + :class:`~prompt_toolkit.layout.HSplit` (horizontal split) and + :class:`~prompt_toolkit.layout.FloatContainer`. These containers arrange the + layout and can split it in multiple regions. Each container can recursively + contain multiple other containers. They can be combined in any way to define + the "shape" of the layout. + + The :class:`~prompt_toolkit.layout.Window` object is a special kind of + container that can contain a :class:`~prompt_toolkit.layout.UIControl` + object. The :class:`~prompt_toolkit.layout.UIControl` object is responsible + for the generation of the actual content. The + :class:`~prompt_toolkit.layout.Window` object acts as an adaptor between the + :class:`~prompt_toolkit.layout.UIControl` and other containers, but it's also + responsible for the scrolling and line wrapping of the content. + + Examples of :class:`~prompt_toolkit.layout.UIControl` objects are + :class:`~prompt_toolkit.layout.BufferControl` for showing the content of an + editable/scrollable buffer, and + :class:`~prompt_toolkit.layout.FormattedTextControl` for displaying + (:ref:`formatted <formatted_text>`) text. + + Normally, it is never needed to create new + :class:`~prompt_toolkit.layout.UIControl` or + :class:`~prompt_toolkit.layout.Container` classes, but instead you would + create the layout by composing instances of the existing built-ins. + +- A higher level abstraction of building a layout is by using "widgets". A + widget is a reusable layout component that can contain multiple containers + and controls. Widgets have a ``__pt_container__`` function, which returns + the root container for this widget. Prompt_toolkit contains a couple of + widgets like :class:`~prompt_toolkit.widgets.TextArea`, + :class:`~prompt_toolkit.widgets.Button`, + :class:`~prompt_toolkit.widgets.Frame`, + :class:`~prompt_toolkit.widgets.VerticalLine` and so on. + +- The highest level abstractions can be found in the ``shortcuts`` module. + There we don't have to think about the layout, controls and containers at + all. This is the simplest way to use prompt_toolkit, but is only meant for + specific use cases, like a prompt or a simple dialog window. + +Containers and controls +^^^^^^^^^^^^^^^^^^^^^^^ + +The biggest difference between containers and controls is that containers +arrange the layout by splitting the screen in many regions, while controls are +responsible for generating the actual content. + +.. note:: + + Under the hood, the difference is: + + - containers use *absolute coordinates*, and paint on a + :class:`~prompt_toolkit.layout.screen.Screen` instance. + - user controls create a :class:`~prompt_toolkit.layout.controls.UIContent` + instance. This is a collection of lines that represent the actual + content. A :class:`~prompt_toolkit.layout.controls.UIControl` is not aware + of the screen. + ++---------------------------------------------+------------------------------------------------------+ +| Abstract base class | Examples | ++=============================================+======================================================+ +| :class:`~prompt_toolkit.layout.Container` | :class:`~prompt_toolkit.layout.HSplit` | +| | :class:`~prompt_toolkit.layout.VSplit` | +| | :class:`~prompt_toolkit.layout.FloatContainer` | +| | :class:`~prompt_toolkit.layout.Window` | +| | :class:`~prompt_toolkit.layout.ScrollablePane` | ++---------------------------------------------+------------------------------------------------------+ +| :class:`~prompt_toolkit.layout.UIControl` | :class:`~prompt_toolkit.layout.BufferControl` | +| | :class:`~prompt_toolkit.layout.FormattedTextControl` | ++---------------------------------------------+------------------------------------------------------+ + +The :class:`~prompt_toolkit.layout.Window` class itself is +particular: it is a :class:`~prompt_toolkit.layout.Container` that +can contain a :class:`~prompt_toolkit.layout.UIControl`. Thus, it's the adaptor +between the two. The :class:`~prompt_toolkit.layout.Window` class also takes +care of scrolling the content and wrapping the lines if needed. + +Finally, there is the :class:`~prompt_toolkit.layout.Layout` class which wraps +the whole layout. This is responsible for keeping track of which window has the +focus. + +Here is an example of a layout that displays the content of the default buffer +on the left, and displays ``"Hello world"`` on the right. In between it shows a +vertical line: + +.. code:: python + + from prompt_toolkit import Application + from prompt_toolkit.buffer import Buffer + from prompt_toolkit.layout.containers import VSplit, Window + from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl + from prompt_toolkit.layout.layout import Layout + + buffer1 = Buffer() # Editable buffer. + + root_container = VSplit([ + # One window that holds the BufferControl with the default buffer on + # the left. + Window(content=BufferControl(buffer=buffer1)), + + # A vertical line in the middle. We explicitly specify the width, to + # make sure that the layout engine will not try to divide the whole + # width by three for all these windows. The window will simply fill its + # content by repeating this character. + Window(width=1, char='|'), + + # Display the text 'Hello world' on the right. + Window(content=FormattedTextControl(text='Hello world')), + ]) + + layout = Layout(root_container) + + app = Application(layout=layout, full_screen=True) + app.run() # You won't be able to Exit this app + +Notice that if you execute this right now, there is no way to quit this +application yet. This is something we explain in the next section below. + +More complex layouts can be achieved by nesting multiple +:class:`~prompt_toolkit.layout.VSplit`, +:class:`~prompt_toolkit.layout.HSplit` and +:class:`~prompt_toolkit.layout.FloatContainer` objects. + +If you want to make some part of the layout only visible when a certain +condition is satisfied, use a +:class:`~prompt_toolkit.layout.ConditionalContainer`. + +Finally, there is :class:`~prompt_toolkit.layout.ScrollablePane`, a container +class that can be used to create long forms or nested layouts that are +scrollable as a whole. + + +Focusing windows +^^^^^^^^^^^^^^^^^ + +Focusing something can be done by calling the +:meth:`~prompt_toolkit.layout.Layout.focus` method. This method is very +flexible and accepts a :class:`~prompt_toolkit.layout.Window`, a +:class:`~prompt_toolkit.buffer.Buffer`, a +:class:`~prompt_toolkit.layout.controls.UIControl` and more. + +In the following example, we use :func:`~prompt_toolkit.application.get_app` +for getting the active application. + +.. code:: python + + from prompt_toolkit.application import get_app + + # This window was created earlier. + w = Window() + + # ... + + # Now focus it. + get_app().layout.focus(w) + +Changing the focus is something which is typically done in a key binding, so +read on to see how to define key bindings. + +Key bindings +------------ + +In order to react to user actions, we need to create a +:class:`~prompt_toolkit.key_binding.KeyBindings` object and pass +that to our :class:`~prompt_toolkit.application.Application`. + +There are two kinds of key bindings: + +- Global key bindings, which are always active. +- Key bindings that belong to a certain + :class:`~prompt_toolkit.layout.controls.UIControl` and are only active when + this control is focused. Both + :class:`~prompt_toolkit.layout.BufferControl` + :class:`~prompt_toolkit.layout.FormattedTextControl` take a ``key_bindings`` + argument. + + +Global key bindings +^^^^^^^^^^^^^^^^^^^ + +Key bindings can be passed to the application as follows: + +.. code:: python + + from prompt_toolkit import Application + from prompt_toolkit.key_binding import KeyBindings + + kb = KeyBindings() + app = Application(key_bindings=kb) + app.run() + +To register a new keyboard shortcut, we can use the +:meth:`~prompt_toolkit.key_binding.KeyBindings.add` method as a decorator of +the key handler: + +.. code:: python + + from prompt_toolkit import Application + from prompt_toolkit.key_binding import KeyBindings + + kb = KeyBindings() + + @kb.add('c-q') + def exit_(event): + """ + Pressing Ctrl-Q will exit the user interface. + + Setting a return value means: quit the event loop that drives the user + interface and return this value from the `Application.run()` call. + """ + event.app.exit() + + app = Application(key_bindings=kb, full_screen=True) + app.run() + +The callback function is named ``exit_`` for clarity, but it could have been +named ``_`` (underscore) as well, because we won't refer to this name. + +:ref:`Read more about key bindings ...<key_bindings>` + + +Modal containers +^^^^^^^^^^^^^^^^ + +The following container objects take a ``modal`` argument +:class:`~prompt_toolkit.layout.VSplit`, +:class:`~prompt_toolkit.layout.HSplit`, and +:class:`~prompt_toolkit.layout.FloatContainer`. + +Setting ``modal=True`` makes what is called a **modal** container. Normally, a +child container would inherit its parent key bindings. This does not apply to +**modal** containers. + +Consider a **modal** container (e.g. :class:`~prompt_toolkit.layout.VSplit`) +is child of another container, its parent. Any key bindings from the parent +are not taken into account if the **modal** container (child) has the focus. + +This is useful in a complex layout, where many controls have their own key +bindings, but you only want to enable the key bindings for a certain region of +the layout. + +The global key bindings are always active. + + +More about the Window class +--------------------------- + +As said earlier, a :class:`~prompt_toolkit.layout.Window` is a +:class:`~prompt_toolkit.layout.Container` that wraps a +:class:`~prompt_toolkit.layout.UIControl`, like a +:class:`~prompt_toolkit.layout.BufferControl` or +:class:`~prompt_toolkit.layout.FormattedTextControl`. + +.. note:: + + Basically, windows are the leafs in the tree structure that represent the UI. + +A :class:`~prompt_toolkit.layout.Window` provides a "view" on the +:class:`~prompt_toolkit.layout.UIControl`, which provides lines of content. The +window is in the first place responsible for the line wrapping and scrolling of +the content, but there are much more options. + +- Adding left or right margins. These are used for displaying scroll bars or + line numbers. +- There are the `cursorline` and `cursorcolumn` options. These allow + highlighting the line or column of the cursor position. +- Alignment of the content. The content can be left aligned, right aligned or + centered. +- Finally, the background can be filled with a default character. + + +More about buffers and `BufferControl` +-------------------------------------- + + + +Input processors +^^^^^^^^^^^^^^^^ + +A :class:`~prompt_toolkit.layout.processors.Processor` is used to postprocess +the content of a :class:`~prompt_toolkit.layout.BufferControl` before it's +displayed. It can for instance highlight matching brackets or change the +visualization of tabs and so on. + +A :class:`~prompt_toolkit.layout.processors.Processor` operates on individual +lines. Basically, it takes a (formatted) line and produces a new (formatted) +line. + +Some build-in processors: + ++----------------------------------------------------------------------------+-----------------------------------------------------------+ +| Processor | Usage: | ++============================================================================+===========================================================+ +| :class:`~prompt_toolkit.layout.processors.HighlightSearchProcessor` | Highlight the current search results. | ++----------------------------------------------------------------------------+-----------------------------------------------------------+ +| :class:`~prompt_toolkit.layout.processors.HighlightSelectionProcessor` | Highlight the selection. | ++----------------------------------------------------------------------------+-----------------------------------------------------------+ +| :class:`~prompt_toolkit.layout.processors.PasswordProcessor` | Display input as asterisks. (``*`` characters). | ++----------------------------------------------------------------------------+-----------------------------------------------------------+ +| :class:`~prompt_toolkit.layout.processors.BracketsMismatchProcessor` | Highlight open/close mismatches for brackets. | ++----------------------------------------------------------------------------+-----------------------------------------------------------+ +| :class:`~prompt_toolkit.layout.processors.BeforeInput` | Insert some text before. | ++----------------------------------------------------------------------------+-----------------------------------------------------------+ +| :class:`~prompt_toolkit.layout.processors.AfterInput` | Insert some text after. | ++----------------------------------------------------------------------------+-----------------------------------------------------------+ +| :class:`~prompt_toolkit.layout.processors.AppendAutoSuggestion` | Append auto suggestion text. | ++----------------------------------------------------------------------------+-----------------------------------------------------------+ +| :class:`~prompt_toolkit.layout.processors.ShowLeadingWhiteSpaceProcessor` | Visualize leading whitespace. | ++----------------------------------------------------------------------------+-----------------------------------------------------------+ +| :class:`~prompt_toolkit.layout.processors.ShowTrailingWhiteSpaceProcessor` | Visualize trailing whitespace. | ++----------------------------------------------------------------------------+-----------------------------------------------------------+ +| :class:`~prompt_toolkit.layout.processors.TabsProcessor` | Visualize tabs as `n` spaces, or some symbols. | ++----------------------------------------------------------------------------+-----------------------------------------------------------+ + +A :class:`~prompt_toolkit.layout.BufferControl` takes only one processor as +input, but it is possible to "merge" multiple processors into one with the +:func:`~prompt_toolkit.layout.processors.merge_processors` function. diff --git a/docs/pages/gallery.rst b/docs/pages/gallery.rst new file mode 100644 index 0000000..40b6917 --- /dev/null +++ b/docs/pages/gallery.rst @@ -0,0 +1,32 @@ +.. _gallery: + +Gallery +======= + +Showcase, demonstrating the possibilities of prompt_toolkit. + +Ptpython, a Python REPL +^^^^^^^^^^^^^^^^^^^^^^^ + +The prompt: + +.. image:: ../images/ptpython.png + +The configuration menu of ptpython. + +.. image:: ../images/ptpython-menu.png + +The history page with its help. (This is a full-screen layout.) + +.. image:: ../images/ptpython-history-help.png + +Pyvim, a Vim clone +^^^^^^^^^^^^^^^^^^ + +.. image:: ../images/pyvim.png + + +Pymux, a terminal multiplexer (like tmux) in Python +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. image:: ../images/pymux.png diff --git a/docs/pages/getting_started.rst b/docs/pages/getting_started.rst new file mode 100644 index 0000000..06287a0 --- /dev/null +++ b/docs/pages/getting_started.rst @@ -0,0 +1,84 @@ +.. _getting_started: + +Getting started +=============== + +Installation +------------ + +:: + + pip install prompt_toolkit + +For Conda, do: + +:: + + conda install -c https://conda.anaconda.org/conda-forge prompt_toolkit + + +Several use cases: prompts versus full screen terminal applications +-------------------------------------------------------------------- + +`prompt_toolkit` was in the first place meant to be a replacement for readline. +However, when it became more mature, we realized that all the components for +full screen applications are there and `prompt_toolkit` is very capable of +handling many use situations. `Pyvim +<http://github.com/prompt-toolkit/pyvim>`_ and `pymux +<http://github.com/prompt-toolkit/pymux>`_ are examples of full screen +applications. + +.. image:: ../images/pyvim.png + +Basically, at the core, `prompt_toolkit` has a layout engine, that supports +horizontal and vertical splits as well as floats, where each "window" can +display a user control. The API for user controls is simple yet powerful. + +When `prompt_toolkit` is used as a readline replacement, (to simply read some +input from the user), it uses a rather simple built-in layout. One that +displays the default input buffer and the prompt, a float for the +autocompletions and a toolbar for input validation which is hidden by default. + +For full screen applications, usually we build a custom layout ourselves. + +Further, there is a very flexible key binding system that can be programmed for +all the needs of full screen applications. + + +A simple prompt +--------------- + +The following snippet is the most simple example, it uses the +:func:`~prompt_toolkit.shortcuts.prompt` function to asks the user for input +and returns the text. Just like ``(raw_)input``. + +.. code:: python + + from prompt_toolkit import prompt + + text = prompt('Give me some input: ') + print('You said: %s' % text) + + +Learning `prompt_toolkit` +------------------------- + +In order to learn and understand `prompt_toolkit`, it is best to go through the +all sections in the order below. Also don't forget to have a look at all the +`examples +<https://github.com/prompt-toolkit/python-prompt-toolkit/tree/master/examples>`_ +in the repository. + +- First, :ref:`learn how to print text <printing_text>`. This is important, + because it covers how to use "formatted text", which is something you'll use + whenever you want to use colors anywhere. + +- Secondly, go through the :ref:`asking for input <asking_for_input>` section. + This is useful for almost any use case, even for full screen applications. + It covers autocompletions, syntax highlighting, key bindings, and so on. + +- Then, learn about :ref:`dialogs`, which is easy and fun. + +- Finally, learn about :ref:`full screen applications + <full_screen_applications>` and read through :ref:`the advanced topics + <advanced_topics>`. diff --git a/docs/pages/printing_text.rst b/docs/pages/printing_text.rst new file mode 100644 index 0000000..8359d5f --- /dev/null +++ b/docs/pages/printing_text.rst @@ -0,0 +1,274 @@ +.. _printing_text: + +Printing (and using) formatted text +=================================== + +Prompt_toolkit ships with a +:func:`~prompt_toolkit.shortcuts.print_formatted_text` function that's meant to +be (as much as possible) compatible with the built-in print function, but on +top of that, also supports colors and formatting. + +On Linux systems, this will output VT100 escape sequences, while on Windows it +will use Win32 API calls or VT100 sequences, depending on what is available. + +.. note:: + + This page is also useful if you'd like to learn how to use formatting + in other places, like in a prompt or a toolbar. Just like + :func:`~prompt_toolkit.shortcuts.print_formatted_text` takes any kind + of "formatted text" as input, prompts and toolbars also accept + "formatted text". + +Printing plain text +------------------- + +The print function can be imported as follows: + +.. code:: python + + from prompt_toolkit import print_formatted_text + + print_formatted_text('Hello world') + +You can replace the built in ``print`` function as follows, if you want to. + +.. code:: python + + from prompt_toolkit import print_formatted_text as print + + print('Hello world') + +.. note:: + + If you're using Python 2, make sure to add ``from __future__ import + print_function``. Otherwise, it will not be possible to import a function + named ``print``. + +.. _formatted_text: + +Formatted text +-------------- + +There are several ways to display colors: + +- By creating an :class:`~prompt_toolkit.formatted_text.HTML` object. +- By creating an :class:`~prompt_toolkit.formatted_text.ANSI` object that + contains ANSI escape sequences. +- By creating a list of ``(style, text)`` tuples. +- By creating a list of ``(pygments.Token, text)`` tuples, and wrapping it in + :class:`~prompt_toolkit.formatted_text.PygmentsTokens`. + +An instance of any of these four kinds of objects is called "formatted text". +There are various places in prompt toolkit, where we accept not just plain text +(as a string), but also formatted text. + +HTML +^^^^ + +:class:`~prompt_toolkit.formatted_text.HTML` can be used to indicate that a +string contains HTML-like formatting. It recognizes the basic tags for bold, +italic and underline: ``<b>``, ``<i>`` and ``<u>``. + +.. code:: python + + from prompt_toolkit import print_formatted_text, HTML + + print_formatted_text(HTML('<b>This is bold</b>')) + print_formatted_text(HTML('<i>This is italic</i>')) + print_formatted_text(HTML('<u>This is underlined</u>')) + +Further, it's possible to use tags for foreground colors: + +.. code:: python + + # Colors from the ANSI palette. + print_formatted_text(HTML('<ansired>This is red</ansired>')) + print_formatted_text(HTML('<ansigreen>This is green</ansigreen>')) + + # Named colors (256 color palette, or true color, depending on the output). + print_formatted_text(HTML('<skyblue>This is sky blue</skyblue>')) + print_formatted_text(HTML('<seagreen>This is sea green</seagreen>')) + print_formatted_text(HTML('<violet>This is violet</violet>')) + +Both foreground and background colors can also be specified setting the `fg` +and `bg` attributes of any HTML tag: + +.. code:: python + + # Colors from the ANSI palette. + print_formatted_text(HTML('<aaa fg="ansiwhite" bg="ansigreen">White on green</aaa>')) + +Underneath, all HTML tags are mapped to classes from a stylesheet, so you can +assign a style for a custom tag. + +.. code:: python + + from prompt_toolkit import print_formatted_text, HTML + from prompt_toolkit.styles import Style + + style = Style.from_dict({ + 'aaa': '#ff0066', + 'bbb': '#44ff00 italic', + }) + + print_formatted_text(HTML('<aaa>Hello</aaa> <bbb>world</bbb>!'), style=style) + + +ANSI +^^^^ + +Some people like to use the VT100 ANSI escape sequences to generate output. +Natively, this is however only supported on VT100 terminals, but prompt_toolkit +can parse these, and map them to formatted text instances. This means that they +will work on Windows as well. The :class:`~prompt_toolkit.formatted_text.ANSI` +class takes care of that. + +.. code:: python + + from prompt_toolkit import print_formatted_text, ANSI + + print_formatted_text(ANSI('\x1b[31mhello \x1b[32mworld')) + +Keep in mind that even on a Linux VT100 terminal, the final output produced by +prompt_toolkit, is not necessarily exactly the same. Depending on the color +depth, it is possible that colors are mapped to different colors, and unknown +tags will be removed. + + +(style, text) tuples +^^^^^^^^^^^^^^^^^^^^ + +Internally, both :class:`~prompt_toolkit.formatted_text.HTML` and +:class:`~prompt_toolkit.formatted_text.ANSI` objects are mapped to a list of +``(style, text)`` tuples. It is however also possible to create such a list +manually with :class:`~prompt_toolkit.formatted_text.FormattedText` class. +This is a little more verbose, but it's probably the most powerful +way of expressing formatted text. + +.. code:: python + + from prompt_toolkit import print_formatted_text + from prompt_toolkit.formatted_text import FormattedText + + text = FormattedText([ + ('#ff0066', 'Hello'), + ('', ' '), + ('#44ff00 italic', 'World'), + ]) + + print_formatted_text(text) + +Similar to the :class:`~prompt_toolkit.formatted_text.HTML` example, it is also +possible to use class names, and separate the styling in a style sheet. + +.. code:: python + + from prompt_toolkit import print_formatted_text + from prompt_toolkit.formatted_text import FormattedText + from prompt_toolkit.styles import Style + + # The text. + text = FormattedText([ + ('class:aaa', 'Hello'), + ('', ' '), + ('class:bbb', 'World'), + ]) + + # The style sheet. + style = Style.from_dict({ + 'aaa': '#ff0066', + 'bbb': '#44ff00 italic', + }) + + print_formatted_text(text, style=style) + + +Pygments ``(Token, text)`` tuples +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When you have a list of `Pygments <http://pygments.org/>`_ ``(Token, text)`` +tuples, then these can be printed by wrapping them in a +:class:`~prompt_toolkit.formatted_text.PygmentsTokens` object. + +.. code:: python + + from pygments.token import Token + from prompt_toolkit import print_formatted_text + from prompt_toolkit.formatted_text import PygmentsTokens + + text = [ + (Token.Keyword, 'print'), + (Token.Punctuation, '('), + (Token.Literal.String.Double, '"'), + (Token.Literal.String.Double, 'hello'), + (Token.Literal.String.Double, '"'), + (Token.Punctuation, ')'), + (Token.Text, '\n'), + ] + + print_formatted_text(PygmentsTokens(text)) + + +Similarly, it is also possible to print the output of a Pygments lexer: + +.. code:: python + + import pygments + from pygments.token import Token + from pygments.lexers.python import PythonLexer + + from prompt_toolkit.formatted_text import PygmentsTokens + from prompt_toolkit import print_formatted_text + + # Printing the output of a pygments lexer. + tokens = list(pygments.lex('print("Hello")', lexer=PythonLexer())) + print_formatted_text(PygmentsTokens(tokens)) + +Prompt_toolkit ships with a default colorscheme which styles it just like +Pygments would do, but if you'd like to change the colors, keep in mind that +Pygments tokens map to classnames like this: + ++-----------------------------------+---------------------------------------------+ +| pygments.Token | prompt_toolkit classname | ++===================================+=============================================+ +| - ``Token.Keyword`` | - ``"class:pygments.keyword"`` | +| - ``Token.Punctuation`` | - ``"class:pygments.punctuation"`` | +| - ``Token.Literal.String.Double`` | - ``"class:pygments.literal.string.double"``| +| - ``Token.Text`` | - ``"class:pygments.text"`` | +| - ``Token`` | - ``"class:pygments"`` | ++-----------------------------------+---------------------------------------------+ + +A classname like ``pygments.literal.string.double`` is actually decomposed in +the following four classnames: ``pygments``, ``pygments.literal``, +``pygments.literal.string`` and ``pygments.literal.string.double``. The final +style is computed by combining the style for these four classnames. So, +changing the style from these Pygments tokens can be done as follows: + +.. code:: python + + from prompt_toolkit.styles import Style + + style = Style.from_dict({ + 'pygments.keyword': 'underline', + 'pygments.literal.string': 'bg:#00ff00 #ffffff', + }) + print_formatted_text(PygmentsTokens(tokens), style=style) + + +to_formatted_text +^^^^^^^^^^^^^^^^^ + +A useful function to know about is +:func:`~prompt_toolkit.formatted_text.to_formatted_text`. This ensures that the +given input is valid formatted text. While doing so, an additional style can be +applied as well. + +.. code:: python + + from prompt_toolkit.formatted_text import to_formatted_text, HTML + from prompt_toolkit import print_formatted_text + + html = HTML('<aaa>Hello</aaa> <bbb>world</bbb>!') + text = to_formatted_text(html, style='class:my_html bg:#00ff00 italic') + + print_formatted_text(text) diff --git a/docs/pages/progress_bars.rst b/docs/pages/progress_bars.rst new file mode 100644 index 0000000..54a8ee1 --- /dev/null +++ b/docs/pages/progress_bars.rst @@ -0,0 +1,248 @@ +.. _progress_bars: + +Progress bars +============= + +Prompt_toolkit ships with a high level API for displaying progress bars, +inspired by `tqdm <https://github.com/tqdm/tqdm>`_ + +.. warning:: + + The API for the prompt_toolkit progress bars is still very new and can + possibly change in the future. It is usable and tested, but keep this in + mind when upgrading. + +Remember that the `examples directory <https://github.com/prompt-toolkit/python-prompt-toolkit/tree/master/examples>`_ +of the prompt_toolkit repository ships with many progress bar examples as well. + + +Simple progress bar +------------------- + +Creating a new progress bar can be done by calling the +:class:`~prompt_toolkit.shortcuts.ProgressBar` context manager. + +The progress can be displayed for any iterable. This works by wrapping the +iterable (like ``range``) with the +:class:`~prompt_toolkit.shortcuts.ProgressBar` context manager itself. This +way, the progress bar knows when the next item is consumed by the forloop and +when progress happens. + +.. code:: python + + from prompt_toolkit.shortcuts import ProgressBar + import time + + + with ProgressBar() as pb: + for i in pb(range(800)): + time.sleep(.01) + +.. image:: ../images/progress-bars/simple-progress-bar.png + +Keep in mind that not all iterables can report their total length. This happens +with a typical generator. In that case, you can still pass the total as follows +in order to make displaying the progress possible: + +.. code:: python + + def some_iterable(): + yield ... + + with ProgressBar() as pb: + for i in pb(some_iterable(), total=1000): + time.sleep(.01) + + +Multiple parallel tasks +----------------------- + +A prompt_toolkit :class:`~prompt_toolkit.shortcuts.ProgressBar` can display the +progress of multiple tasks running in parallel. Each task can run in a separate +thread and the :class:`~prompt_toolkit.shortcuts.ProgressBar` user interface +runs in its own thread. + +Notice that we set the "daemon" flag for both threads that run the tasks. This +is because control-c will stop the progress and quit our application. We don't +want the application to wait for the background threads to finish. Whether you +want this depends on the application. + +.. code:: python + + from prompt_toolkit.shortcuts import ProgressBar + import time + import threading + + + with ProgressBar() as pb: + # Two parallel tasks. + def task_1(): + for i in pb(range(100)): + time.sleep(.05) + + def task_2(): + for i in pb(range(150)): + time.sleep(.08) + + # Start threads. + t1 = threading.Thread(target=task_1) + t2 = threading.Thread(target=task_2) + t1.daemon = True + t2.daemon = True + t1.start() + t2.start() + + # Wait for the threads to finish. We use a timeout for the join() call, + # because on Windows, join cannot be interrupted by Control-C or any other + # signal. + for t in [t1, t2]: + while t.is_alive(): + t.join(timeout=.5) + +.. image:: ../images/progress-bars/two-tasks.png + + +Adding a title and label +------------------------ + +Each progress bar can have one title, and for each task an individual label. +Both the title and the labels can be :ref:`formatted text <formatted_text>`. + +.. code:: python + + from prompt_toolkit.shortcuts import ProgressBar + from prompt_toolkit.formatted_text import HTML + import time + + title = HTML('Downloading <style bg="yellow" fg="black">4 files...</style>') + label = HTML('<ansired>some file</ansired>: ') + + with ProgressBar(title=title) as pb: + for i in pb(range(800), label=label): + time.sleep(.01) + +.. image:: ../images/progress-bars/colored-title-and-label.png + + +Formatting the progress bar +--------------------------- + +The visualization of a :class:`~prompt_toolkit.shortcuts.ProgressBar` can be +customized by using a different sequence of formatters. The default formatting +looks something like this: + +.. code:: python + + from prompt_toolkit.shortcuts.progress_bar.formatters import * + + default_formatting = [ + Label(), + Text(' '), + Percentage(), + Text(' '), + Bar(), + Text(' '), + Progress(), + Text(' '), + Text('eta [', style='class:time-left'), + TimeLeft(), + Text(']', style='class:time-left'), + Text(' '), + ] + +That sequence of +:class:`~prompt_toolkit.shortcuts.progress_bar.formatters.Formatter` can be +passed to the `formatter` argument of +:class:`~prompt_toolkit.shortcuts.ProgressBar`. So, we could change this and +modify the progress bar to look like an apt-get style progress bar: + +.. code:: python + + from prompt_toolkit.shortcuts import ProgressBar + from prompt_toolkit.styles import Style + from prompt_toolkit.shortcuts.progress_bar import formatters + import time + + style = Style.from_dict({ + 'label': 'bg:#ffff00 #000000', + 'percentage': 'bg:#ffff00 #000000', + 'current': '#448844', + 'bar': '', + }) + + + custom_formatters = [ + formatters.Label(), + formatters.Text(': [', style='class:percentage'), + formatters.Percentage(), + formatters.Text(']', style='class:percentage'), + formatters.Text(' '), + formatters.Bar(sym_a='#', sym_b='#', sym_c='.'), + formatters.Text(' '), + ] + + with ProgressBar(style=style, formatters=custom_formatters) as pb: + for i in pb(range(1600), label='Installing'): + time.sleep(.01) + +.. image:: ../images/progress-bars/apt-get.png + + +Adding key bindings and toolbar +------------------------------- + +Like other prompt_toolkit applications, we can add custom key bindings, by +passing a :class:`~prompt_toolkit.key_binding.KeyBindings` object: + +.. code:: python + + from prompt_toolkit import HTML + from prompt_toolkit.key_binding import KeyBindings + from prompt_toolkit.patch_stdout import patch_stdout + from prompt_toolkit.shortcuts import ProgressBar + + import os + import time + import signal + + bottom_toolbar = HTML(' <b>[f]</b> Print "f" <b>[x]</b> Abort.') + + # Create custom key bindings first. + kb = KeyBindings() + cancel = [False] + + @kb.add('f') + def _(event): + print('You pressed `f`.') + + @kb.add('x') + def _(event): + " Send Abort (control-c) signal. " + cancel[0] = True + os.kill(os.getpid(), signal.SIGINT) + + # Use `patch_stdout`, to make sure that prints go above the + # application. + with patch_stdout(): + with ProgressBar(key_bindings=kb, bottom_toolbar=bottom_toolbar) as pb: + for i in pb(range(800)): + time.sleep(.01) + + # Stop when the cancel flag has been set. + if cancel[0]: + break + +Notice that we use :func:`~prompt_toolkit.patch_stdout.patch_stdout` to make +printing text possible while the progress bar is displayed. This ensures that +printing happens above the progress bar. + +Further, when "x" is pressed, we set a cancel flag, which stops the progress. +It would also be possible to send `SIGINT` to the mean thread, but that's not +always considered a clean way of cancelling something. + +In the example above, we also display a toolbar at the bottom which shows the +key bindings. + +.. image:: ../images/progress-bars/custom-key-bindings.png + +:ref:`Read more about key bindings ...<key_bindings>` diff --git a/docs/pages/reference.rst b/docs/pages/reference.rst new file mode 100644 index 0000000..d8a705e --- /dev/null +++ b/docs/pages/reference.rst @@ -0,0 +1,393 @@ +Reference +========= + +Application +----------- + +.. automodule:: prompt_toolkit.application + :members: Application, get_app, get_app_or_none, set_app, + create_app_session, AppSession, get_app_session, DummyApplication, + in_terminal, run_in_terminal, + + +Formatted text +-------------- + +.. automodule:: prompt_toolkit.formatted_text + :members: + + +Buffer +------ + +.. automodule:: prompt_toolkit.buffer + :members: + + +Selection +--------- + +.. automodule:: prompt_toolkit.selection + :members: + + +Clipboard +--------- + +.. automodule:: prompt_toolkit.clipboard + :members: Clipboard, ClipboardData, DummyClipboard, DynamicClipboard, InMemoryClipboard + +.. automodule:: prompt_toolkit.clipboard.pyperclip + :members: + + +Auto completion +--------------- + +.. automodule:: prompt_toolkit.completion + :members: + + +Document +-------- + +.. automodule:: prompt_toolkit.document + :members: + + +Enums +----- + +.. automodule:: prompt_toolkit.enums + :members: + + +History +------- + +.. automodule:: prompt_toolkit.history + :members: + + +Keys +---- + +.. automodule:: prompt_toolkit.keys + :members: + + +Style +----- + +.. automodule:: prompt_toolkit.styles + :members: Attrs, ANSI_COLOR_NAMES, BaseStyle, DummyStyle, DynamicStyle, + Style, Priority, merge_styles, style_from_pygments_cls, + style_from_pygments_dict, pygments_token_to_classname, NAMED_COLORS, + StyleTransformation, SwapLightAndDarkStyleTransformation, + AdjustBrightnessStyleTransformation, merge_style_transformations, + DummyStyleTransformation, ConditionalStyleTransformation, + DynamicStyleTransformation + + +Shortcuts +--------- + +.. automodule:: prompt_toolkit.shortcuts + :members: prompt, PromptSession, confirm, CompleteStyle, + create_confirm_session, clear, clear_title, print_formatted_text, + set_title, ProgressBar, input_dialog, message_dialog, progress_dialog, + radiolist_dialog, yes_no_dialog, button_dialog + +.. automodule:: prompt_toolkit.shortcuts.progress_bar.formatters + :members: + + +Validation +---------- + +.. automodule:: prompt_toolkit.validation + :members: + + +Auto suggestion +--------------- + +.. automodule:: prompt_toolkit.auto_suggest + :members: + + +Renderer +-------- + +.. automodule:: prompt_toolkit.renderer + :members: + +Lexers +------ + +.. automodule:: prompt_toolkit.lexers + :members: + + +Layout +------ + +.. automodule:: prompt_toolkit.layout + +The layout class itself +^^^^^^^^^^^^^^^^^^^^^^^ + +.. autoclass:: prompt_toolkit.layout.Layout + :members: + +.. autoclass:: prompt_toolkit.layout.InvalidLayoutError + :members: + +.. autoclass:: prompt_toolkit.layout.walk + :members: + +Containers +^^^^^^^^^^ + +.. autoclass:: prompt_toolkit.layout.Container + :members: + +.. autoclass:: prompt_toolkit.layout.HSplit + :members: + +.. autoclass:: prompt_toolkit.layout.VSplit + :members: + +.. autoclass:: prompt_toolkit.layout.FloatContainer + :members: + +.. autoclass:: prompt_toolkit.layout.Float + :members: + +.. autoclass:: prompt_toolkit.layout.Window + :members: + +.. autoclass:: prompt_toolkit.layout.WindowAlign + :members: + +.. autoclass:: prompt_toolkit.layout.ConditionalContainer + :members: + +.. autoclass:: prompt_toolkit.layout.DynamicContainer + :members: + +.. autoclass:: prompt_toolkit.layout.ScrollablePane + :members: + +.. autoclass:: prompt_toolkit.layout.ScrollOffsets + :members: + +.. autoclass:: prompt_toolkit.layout.ColorColumn + :members: + +.. autoclass:: prompt_toolkit.layout.to_container + :members: + +.. autoclass:: prompt_toolkit.layout.to_window + :members: + +.. autoclass:: prompt_toolkit.layout.is_container + :members: + +.. autoclass:: prompt_toolkit.layout.HorizontalAlign + :members: + +.. autoclass:: prompt_toolkit.layout.VerticalAlign + :members: + +Controls +^^^^^^^^ + +.. autoclass:: prompt_toolkit.layout.BufferControl + :members: + +.. autoclass:: prompt_toolkit.layout.SearchBufferControl + :members: + +.. autoclass:: prompt_toolkit.layout.DummyControl + :members: + +.. autoclass:: prompt_toolkit.layout.FormattedTextControl + :members: + +.. autoclass:: prompt_toolkit.layout.UIControl + :members: + +.. autoclass:: prompt_toolkit.layout.UIContent + :members: + + +Other +^^^^^ + + +Sizing +"""""" + +.. autoclass:: prompt_toolkit.layout.Dimension + :members: + + +Margins +""""""" + +.. autoclass:: prompt_toolkit.layout.Margin + :members: + +.. autoclass:: prompt_toolkit.layout.NumberedMargin + :members: + +.. autoclass:: prompt_toolkit.layout.ScrollbarMargin + :members: + +.. autoclass:: prompt_toolkit.layout.ConditionalMargin + :members: + +.. autoclass:: prompt_toolkit.layout.PromptMargin + :members: + + +Completion Menus +"""""""""""""""" + +.. autoclass:: prompt_toolkit.layout.CompletionsMenu + :members: + +.. autoclass:: prompt_toolkit.layout.MultiColumnCompletionsMenu + :members: + + +Processors +"""""""""" + +.. automodule:: prompt_toolkit.layout.processors + :members: + + +Utils +""""" + +.. automodule:: prompt_toolkit.layout.utils + :members: + + +Screen +"""""" + +.. automodule:: prompt_toolkit.layout.screen + :members: + + +Widgets +------- + +.. automodule:: prompt_toolkit.widgets + :members: TextArea, Label, Button, Frame, Shadow, Box, VerticalLine, + HorizontalLine, RadioList, Checkbox, ProgressBar, CompletionsToolbar, + FormattedTextToolbar, SearchToolbar, SystemToolbar, ValidationToolbar, + MenuContainer, MenuItem + + +Filters +------- + +.. automodule:: prompt_toolkit.filters + :members: + +.. autoclass:: prompt_toolkit.filters.Filter + :members: + +.. autoclass:: prompt_toolkit.filters.Condition + :members: + +.. automodule:: prompt_toolkit.filters.utils + :members: + +.. automodule:: prompt_toolkit.filters.app + :members: + + +Key binding +----------- + +.. automodule:: prompt_toolkit.key_binding + :members: KeyBindingsBase, KeyBindings, ConditionalKeyBindings, + merge_key_bindings, DynamicKeyBindings + +.. automodule:: prompt_toolkit.key_binding.defaults + :members: + +.. automodule:: prompt_toolkit.key_binding.vi_state + :members: + +.. automodule:: prompt_toolkit.key_binding.key_processor + :members: + + +Eventloop +--------- + +.. automodule:: prompt_toolkit.eventloop + :members: run_in_executor_with_context, call_soon_threadsafe, + get_traceback_from_context, get_event_loop + +.. automodule:: prompt_toolkit.eventloop.inputhook + :members: + +.. automodule:: prompt_toolkit.eventloop.utils + :members: + + +Input +----- + +.. automodule:: prompt_toolkit.input + :members: Input, DummyInput, create_input, create_pipe_input + +.. automodule:: prompt_toolkit.input.vt100 + :members: + +.. automodule:: prompt_toolkit.input.vt100_parser + :members: + +.. automodule:: prompt_toolkit.input.ansi_escape_sequences + :members: + +.. automodule:: prompt_toolkit.input.win32 + :members: + +Output +------ + +.. automodule:: prompt_toolkit.output + :members: Output, DummyOutput, ColorDepth, create_output + +.. automodule:: prompt_toolkit.output.vt100 + :members: + +.. automodule:: prompt_toolkit.output.win32 + :members: + + +Data structures +--------------- + +.. autoclass:: prompt_toolkit.layout.WindowRenderInfo + :members: + +.. autoclass:: prompt_toolkit.data_structures.Point + :members: + +.. autoclass:: prompt_toolkit.data_structures.Size + :members: + +Patch stdout +------------ + +.. automodule:: prompt_toolkit.patch_stdout + :members: patch_stdout, StdoutProxy diff --git a/docs/pages/related_projects.rst b/docs/pages/related_projects.rst new file mode 100644 index 0000000..ad0a8af --- /dev/null +++ b/docs/pages/related_projects.rst @@ -0,0 +1,11 @@ +.. _related_projects: + +Related projects +================ + +There are some other Python libraries that provide similar functionality that +are also worth checking out: + +- `Urwid <http://urwid.org/>`_ +- `Textual <https://textual.textualize.io/>`_ +- `Rich <https://rich.readthedocs.io/>`_ diff --git a/docs/pages/tutorials/index.rst b/docs/pages/tutorials/index.rst new file mode 100644 index 0000000..827b511 --- /dev/null +++ b/docs/pages/tutorials/index.rst @@ -0,0 +1,10 @@ +.. _tutorials: + +Tutorials +========= + +.. toctree:: + :caption: Contents: + :maxdepth: 1 + + repl diff --git a/docs/pages/tutorials/repl.rst b/docs/pages/tutorials/repl.rst new file mode 100644 index 0000000..946786f --- /dev/null +++ b/docs/pages/tutorials/repl.rst @@ -0,0 +1,341 @@ +.. _tutorial_repl: + +Tutorial: Build an SQLite REPL +============================== + +The aim of this tutorial is to build an interactive command line interface for +an SQLite database using prompt_toolkit_. + +First, install the library using pip, if you haven't done this already. + +.. code:: + + pip install prompt_toolkit + + +Read User Input +--------------- + +Let's start accepting input using the +:func:`~prompt_toolkit.shortcuts.prompt()` function. This will ask the user for +input, and echo back whatever the user typed. We wrap it in a ``main()`` +function as a good practice. + +.. code:: python + + from prompt_toolkit import prompt + + def main(): + text = prompt('> ') + print('You entered:', text) + + if __name__ == '__main__': + main() + +.. image:: ../../images/repl/sqlite-1.png + + +Loop The REPL +------------- + +Now we want to call the :meth:`~prompt_toolkit.shortcuts.PromptSession.prompt` +method in a loop. In order to keep the history, the easiest way to do it is to +use a :class:`~prompt_toolkit.shortcuts.PromptSession`. This uses an +:class:`~prompt_toolkit.history.InMemoryHistory` underneath that keeps track of +the history, so that if the user presses the up-arrow, they'll see the previous +entries. + +The :meth:`~prompt_toolkit.shortcuts.PromptSession.prompt` method raises +``KeyboardInterrupt`` when ControlC has been pressed and ``EOFError`` when +ControlD has been pressed. This is what people use for cancelling commands and +exiting in a REPL. The try/except below handles these error conditions and make +sure that we go to the next iteration of the loop or quit the loop +respectively. + +.. code:: python + + from prompt_toolkit import PromptSession + + def main(): + session = PromptSession() + + while True: + try: + text = session.prompt('> ') + except KeyboardInterrupt: + continue + except EOFError: + break + else: + print('You entered:', text) + print('GoodBye!') + + if __name__ == '__main__': + main() + +.. image:: ../../images/repl/sqlite-2.png + + +Syntax Highlighting +------------------- + +This is where things get really interesting. Let's step it up a notch by adding +syntax highlighting to the user input. We know that users will be entering SQL +statements, so we can leverage the Pygments_ library for coloring the input. +The ``lexer`` parameter allows us to set the syntax lexer. We're going to use +the ``SqlLexer`` from the Pygments_ library for highlighting. + +Notice that in order to pass a Pygments lexer to prompt_toolkit, it needs to be +wrapped into a :class:`~prompt_toolkit.lexers.PygmentsLexer`. + +.. code:: python + + from prompt_toolkit import PromptSession + from prompt_toolkit.lexers import PygmentsLexer + from pygments.lexers.sql import SqlLexer + + def main(): + session = PromptSession(lexer=PygmentsLexer(SqlLexer)) + + while True: + try: + text = session.prompt('> ') + except KeyboardInterrupt: + continue + except EOFError: + break + else: + print('You entered:', text) + print('GoodBye!') + + if __name__ == '__main__': + main() + +.. image:: ../../images/repl/sqlite-3.png + + +Auto-completion +--------------- + +Now we are going to add auto completion. We'd like to display a drop down menu +of `possible keywords <https://www.sqlite.org/lang_keywords.html>`_ when the +user starts typing. + +We can do this by creating an `sql_completer` object from the +:class:`~prompt_toolkit.completion.WordCompleter` class, defining a set of +`keywords` for the auto-completion. + +Like the lexer, this ``sql_completer`` instance can be passed to either the +:class:`~prompt_toolkit.shortcuts.PromptSession` class or the +:meth:`~prompt_toolkit.shortcuts.PromptSession.prompt` method. + +.. code:: python + + from prompt_toolkit import PromptSession + from prompt_toolkit.completion import WordCompleter + from prompt_toolkit.lexers import PygmentsLexer + from pygments.lexers.sql import SqlLexer + + sql_completer = WordCompleter([ + 'abort', 'action', 'add', 'after', 'all', 'alter', 'analyze', 'and', + 'as', 'asc', 'attach', 'autoincrement', 'before', 'begin', 'between', + 'by', 'cascade', 'case', 'cast', 'check', 'collate', 'column', + 'commit', 'conflict', 'constraint', 'create', 'cross', 'current_date', + 'current_time', 'current_timestamp', 'database', 'default', + 'deferrable', 'deferred', 'delete', 'desc', 'detach', 'distinct', + 'drop', 'each', 'else', 'end', 'escape', 'except', 'exclusive', + 'exists', 'explain', 'fail', 'for', 'foreign', 'from', 'full', 'glob', + 'group', 'having', 'if', 'ignore', 'immediate', 'in', 'index', + 'indexed', 'initially', 'inner', 'insert', 'instead', 'intersect', + 'into', 'is', 'isnull', 'join', 'key', 'left', 'like', 'limit', + 'match', 'natural', 'no', 'not', 'notnull', 'null', 'of', 'offset', + 'on', 'or', 'order', 'outer', 'plan', 'pragma', 'primary', 'query', + 'raise', 'recursive', 'references', 'regexp', 'reindex', 'release', + 'rename', 'replace', 'restrict', 'right', 'rollback', 'row', + 'savepoint', 'select', 'set', 'table', 'temp', 'temporary', 'then', + 'to', 'transaction', 'trigger', 'union', 'unique', 'update', 'using', + 'vacuum', 'values', 'view', 'virtual', 'when', 'where', 'with', + 'without'], ignore_case=True) + + def main(): + session = PromptSession( + lexer=PygmentsLexer(SqlLexer), completer=sql_completer) + + while True: + try: + text = session.prompt('> ') + except KeyboardInterrupt: + continue + except EOFError: + break + else: + print('You entered:', text) + print('GoodBye!') + + if __name__ == '__main__': + main() + +.. image:: ../../images/repl/sqlite-4.png + +In about 30 lines of code we got ourselves an auto completing, syntax +highlighting REPL. Let's make it even better. + + +Styling the menus +----------------- + +If we want, we can now change the colors of the completion menu. This is +possible by creating a :class:`~prompt_toolkit.styles.Style` instance and +passing it to the :meth:`~prompt_toolkit.shortcuts.PromptSession.prompt` +function. + +.. code:: python + + from prompt_toolkit import PromptSession + from prompt_toolkit.completion import WordCompleter + from prompt_toolkit.lexers import PygmentsLexer + from prompt_toolkit.styles import Style + from pygments.lexers.sql import SqlLexer + + sql_completer = WordCompleter([ + 'abort', 'action', 'add', 'after', 'all', 'alter', 'analyze', 'and', + 'as', 'asc', 'attach', 'autoincrement', 'before', 'begin', 'between', + 'by', 'cascade', 'case', 'cast', 'check', 'collate', 'column', + 'commit', 'conflict', 'constraint', 'create', 'cross', 'current_date', + 'current_time', 'current_timestamp', 'database', 'default', + 'deferrable', 'deferred', 'delete', 'desc', 'detach', 'distinct', + 'drop', 'each', 'else', 'end', 'escape', 'except', 'exclusive', + 'exists', 'explain', 'fail', 'for', 'foreign', 'from', 'full', 'glob', + 'group', 'having', 'if', 'ignore', 'immediate', 'in', 'index', + 'indexed', 'initially', 'inner', 'insert', 'instead', 'intersect', + 'into', 'is', 'isnull', 'join', 'key', 'left', 'like', 'limit', + 'match', 'natural', 'no', 'not', 'notnull', 'null', 'of', 'offset', + 'on', 'or', 'order', 'outer', 'plan', 'pragma', 'primary', 'query', + 'raise', 'recursive', 'references', 'regexp', 'reindex', 'release', + 'rename', 'replace', 'restrict', 'right', 'rollback', 'row', + 'savepoint', 'select', 'set', 'table', 'temp', 'temporary', 'then', + 'to', 'transaction', 'trigger', 'union', 'unique', 'update', 'using', + 'vacuum', 'values', 'view', 'virtual', 'when', 'where', 'with', + 'without'], ignore_case=True) + + style = Style.from_dict({ + 'completion-menu.completion': 'bg:#008888 #ffffff', + 'completion-menu.completion.current': 'bg:#00aaaa #000000', + 'scrollbar.background': 'bg:#88aaaa', + 'scrollbar.button': 'bg:#222222', + }) + + def main(): + session = PromptSession( + lexer=PygmentsLexer(SqlLexer), completer=sql_completer, style=style) + + while True: + try: + text = session.prompt('> ') + except KeyboardInterrupt: + continue + except EOFError: + break + else: + print('You entered:', text) + print('GoodBye!') + + if __name__ == '__main__': + main() + +.. image:: ../../images/repl/sqlite-5.png + +All that's left is hooking up the sqlite backend, which is left as an exercise +for the reader. Just kidding... Keep reading. + + +Hook up Sqlite +-------------- + +This step is the final step to make the SQLite REPL actually work. It's time +to relay the input to SQLite. + +Obviously I haven't done the due diligence to deal with the errors. But it +gives a good idea of how to get started. + +.. code:: python + + #!/usr/bin/env python + import sys + import sqlite3 + + from prompt_toolkit import PromptSession + from prompt_toolkit.completion import WordCompleter + from prompt_toolkit.lexers import PygmentsLexer + from prompt_toolkit.styles import Style + from pygments.lexers.sql import SqlLexer + + sql_completer = WordCompleter([ + 'abort', 'action', 'add', 'after', 'all', 'alter', 'analyze', 'and', + 'as', 'asc', 'attach', 'autoincrement', 'before', 'begin', 'between', + 'by', 'cascade', 'case', 'cast', 'check', 'collate', 'column', + 'commit', 'conflict', 'constraint', 'create', 'cross', 'current_date', + 'current_time', 'current_timestamp', 'database', 'default', + 'deferrable', 'deferred', 'delete', 'desc', 'detach', 'distinct', + 'drop', 'each', 'else', 'end', 'escape', 'except', 'exclusive', + 'exists', 'explain', 'fail', 'for', 'foreign', 'from', 'full', 'glob', + 'group', 'having', 'if', 'ignore', 'immediate', 'in', 'index', + 'indexed', 'initially', 'inner', 'insert', 'instead', 'intersect', + 'into', 'is', 'isnull', 'join', 'key', 'left', 'like', 'limit', + 'match', 'natural', 'no', 'not', 'notnull', 'null', 'of', 'offset', + 'on', 'or', 'order', 'outer', 'plan', 'pragma', 'primary', 'query', + 'raise', 'recursive', 'references', 'regexp', 'reindex', 'release', + 'rename', 'replace', 'restrict', 'right', 'rollback', 'row', + 'savepoint', 'select', 'set', 'table', 'temp', 'temporary', 'then', + 'to', 'transaction', 'trigger', 'union', 'unique', 'update', 'using', + 'vacuum', 'values', 'view', 'virtual', 'when', 'where', 'with', + 'without'], ignore_case=True) + + style = Style.from_dict({ + 'completion-menu.completion': 'bg:#008888 #ffffff', + 'completion-menu.completion.current': 'bg:#00aaaa #000000', + 'scrollbar.background': 'bg:#88aaaa', + 'scrollbar.button': 'bg:#222222', + }) + + def main(database): + connection = sqlite3.connect(database) + session = PromptSession( + lexer=PygmentsLexer(SqlLexer), completer=sql_completer, style=style) + + while True: + try: + text = session.prompt('> ') + except KeyboardInterrupt: + continue # Control-C pressed. Try again. + except EOFError: + break # Control-D pressed. + + with connection: + try: + messages = connection.execute(text) + except Exception as e: + print(repr(e)) + else: + for message in messages: + print(message) + + print('GoodBye!') + + if __name__ == '__main__': + if len(sys.argv) < 2: + db = ':memory:' + else: + db = sys.argv[1] + + main(db) + +.. image:: ../../images/repl/sqlite-6.png + +I hope that gives an idea of how to get started on building command line +interfaces. + +The End. + +.. _prompt_toolkit: https://github.com/prompt-toolkit/python-prompt-toolkit +.. _Pygments: http://pygments.org/ diff --git a/docs/pages/upgrading/2.0.rst b/docs/pages/upgrading/2.0.rst new file mode 100644 index 0000000..6067057 --- /dev/null +++ b/docs/pages/upgrading/2.0.rst @@ -0,0 +1,221 @@ +.. _upgrading_2_0: + +Upgrading to prompt_toolkit 2.0 +=============================== + +Prompt_toolkit 2.0 is not compatible with 1.0, however you probably want to +upgrade your applications. This page explains why we have these differences and +how to upgrade. + +If you experience some difficulties or you feel that some information is +missing from this page, don't hesitate to open a GitHub issue for help. + + +Why all these breaking changes? +------------------------------- + +After more and more custom prompt_toolkit applications were developed, it +became clear that prompt_toolkit 1.0 was not flexible enough for certain use +cases. Mostly, the development of full screen applications was not really +natural. All the important components, like the rendering, key bindings, input +and output handling were present, but the API was in the first place designed +for simple command line prompts. This was mostly notably in the following two +places: + +- First, there was the focus which was always pointing to a + :class:`~prompt_toolkit.buffer.Buffer` (or text input widget), but in full + screen applications there are other widgets, like menus and buttons which + can be focused. +- And secondly, it was impossible to make reusable UI components. All the key + bindings for the entire applications were stored together in one + ``KeyBindings`` object, and similar, all + :class:`~prompt_toolkit.buffer.Buffer` objects were stored together in one + dictionary. This didn't work well. You want reusable components to define + their own key bindings and everything. It's the idea of encapsulation. + +For simple prompts, the changes wouldn't be that invasive, but given that there +would be some, I took the opportunity to fix a couple of other things. For +instance: + +- In prompt_toolkit 1.0, we translated `\\r` into `\\n` during the input + processing. This was not a good idea, because some people wanted to handle + these keys individually. This makes sense if you keep in mind that they + correspond to `Control-M` and `Control-J`. However, we couldn't fix this + without breaking everyone's enter key, which happens to be the most important + key in prompts. + +Given that we were going to break compatibility anyway, we changed a couple of +other important things that effect both simple prompt applications and +full screen applications. These are the most important: + +- We no longer depend on Pygments for styling. While we like Pygments, it was + not flexible enough to provide all the styling options that we need, and the + Pygments tokens were not ideal for styling anything besides tokenized text. + + Instead we created something similar to CSS. All UI components can attach + classnames to themselves, as well as define an inline style. The final style is + then computed by combining the inline styles, the classnames and the style + sheet. + + There are still adaptors available for using Pygments lexers as well as for + Pygments styles. + +- The way that key bindings were defined was too complex. + ``KeyBindingsManager`` was too complex and no longer exists. Every set of key + bindings is now a + :class:`~prompt_toolkit.key_binding.KeyBindings` object and multiple of these + can be merged together at any time. The runtime performance remains the same, + but it's now easier for users. + +- The separation between the ``CommandLineInterface`` and + :class:`~prompt_toolkit.application.Application` class was confusing and in + the end, didn't really had an advantage. These two are now merged together in + one :class:`~prompt_toolkit.application.Application` class. + +- We no longer pass around the active ``CommandLineInterface``. This was one of + the most annoying things. Key bindings need it in order to change anything + and filters need it in order to evaluate their state. It was pretty annoying, + especially because there was usually only one application active at a time. + So, :class:`~prompt_toolkit.application.Application` became a ``TaskLocal``. + That is like a global variable, but scoped in the current coroutine or + context. The way this works is still not 100% correct, but good enough for + the projects that need it (like Pymux), and hopefully Python will get support + for this in the future thanks to PEP521, PEP550 or PEP555. + +All of these changes have been tested for many months, and I can say with +confidence that prompt_toolkit 2.0 is a better prompt_toolkit. + + +Some new features +----------------- + +Apart from the breaking changes above, there are also some exciting new +features. + +- We now support vt100 escape codes for Windows consoles on Windows 10. This + means much faster rendering, and full color support. + +- We have a concept of formatted text. This is an object that evaluates to + styled text. Every input that expects some text, like the message in a + prompt, or the text in a toolbar, can take any kind of formatted text as input. + This means you can pass in a plain string, but also a list of `(style, + text)` tuples (similar to a Pygments tokenized string), or an + :class:`~prompt_toolkit.formatted_text.HTML` object. This simplifies many + APIs. + +- New utilities were added. We now have function for printing formatted text + and an experimental module for displaying progress bars. + +- Autocompletion, input validation, and auto suggestion can now either be + asynchronous or synchronous. By default they are synchronous, but by wrapping + them in :class:`~prompt_toolkit.completion.ThreadedCompleter`, + :class:`~prompt_toolkit.validation.ThreadedValidator` or + :class:`~prompt_toolkit.auto_suggest.ThreadedAutoSuggest`, they will become + asynchronous by running in a background thread. + + Further, if the autocompletion code runs in a background thread, we will show + the completions as soon as they arrive. This means that the autocompletion + algorithm could for instance first yield the most trivial completions and then + take time to produce the completions that take more time. + + +Upgrading +--------- + +More guidelines on how to upgrade will follow. + + +`AbortAction` has been removed +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Prompt_toolkit 1.0 had an argument ``abort_action`` for both the +``Application`` class as well as for the ``prompt`` function. This has been +removed. The recommended way to handle this now is by capturing +``KeyboardInterrupt`` and ``EOFError`` manually. + + +Calling `create_eventloop` usually not required anymore +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Prompt_toolkit 2.0 will automatically create the appropriate event loop when +it's needed for the first time. There is no need to create one and pass it +around. If you want to run an application on top of asyncio (without using an +executor), it still needs to be activated by calling +:func:`~prompt_toolkit.eventloop.use_asyncio_event_loop` at the beginning. + + +Pygments styles and tokens +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +prompt_toolkit 2.0 no longer depends on `Pygments <http://pygments.org/>`_, but +that definitely doesn't mean that you can't use any Pygments functionality +anymore. The only difference is that Pygments stuff needs to be wrapped in an +adaptor to make it compatible with the native prompt_toolkit objects. + +- For instance, if you have a list of ``(pygments.Token, text)`` tuples for + formatting, then this needs to be wrapped in a + :class:`~prompt_toolkit.formatted_text.PygmentsTokens` object. This is an + adaptor that turns it into prompt_toolkit "formatted text". Feel free to keep + using this. + +- Pygments lexers need to be wrapped in a + :class:`~prompt_toolkit.lexers.PygmentsLexer`. This will convert the list of + Pygments tokens into prompt_toolkit formatted text. + +- If you have a Pygments style, then this needs to be converted as well. A + Pygments style class can be converted in a prompt_toolkit + :class:`~prompt_toolkit.styles.Style` with the + :func:`~prompt_toolkit.styles.pygments.style_from_pygments_cls` function + (which used to be called ``style_from_pygments``). A Pygments style + dictionary can be converted using + :func:`~prompt_toolkit.styles.pygments.style_from_pygments_dict`. + + Multiple styles can be merged together using + :func:`~prompt_toolkit.styles.merge_styles`. + + +Wordcompleter +^^^^^^^^^^^^^ + +`WordCompleter` was moved from +:class:`prompt_toolkit.contrib.completers.base.WordCompleter` to +:class:`prompt_toolkit.completion.word_completer.WordCompleter`. + + +Asynchronous autocompletion +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +By default, prompt_toolkit 2.0 completion is now synchronous. If you still want +asynchronous auto completion (which is often good thing), then you have to wrap +the completer in a :class:`~prompt_toolkit.completion.ThreadedCompleter`. + + +Filters +^^^^^^^ + +We don't distinguish anymore between `CLIFilter` and `SimpleFilter`, because the +application object is no longer passed around. This means that all filters are +a `Filter` from now on. + +All filters have been turned into functions. For instance, `IsDone` became +`is_done` and `HasCompletions` became `has_completions`. + +This was done because almost all classes were called without any arguments in +the `__init__` causing additional braces everywhere. This means that +`HasCompletions()` has to be replaced by `has_completions` (without +parenthesis). + +The few filters that took arguments as input, became functions, but still have +to be called with the given arguments. + +For new filters, it is recommended to use the `@Condition` decorator, +rather then inheriting from `Filter`. For instance: + +.. code:: python + + from prompt_toolkit.filters import Condition + + @Condition + def my_filter(); + return True # Or False + diff --git a/docs/pages/upgrading/3.0.rst b/docs/pages/upgrading/3.0.rst new file mode 100644 index 0000000..7a867c5 --- /dev/null +++ b/docs/pages/upgrading/3.0.rst @@ -0,0 +1,118 @@ +.. _upgrading_3_0: + +Upgrading to prompt_toolkit 3.0 +=============================== + +There are two major changes in 3.0 to be aware of: + +- First, prompt_toolkit uses the asyncio event loop natively, rather then using + its own implementations of event loops. This means that all coroutines are + now asyncio coroutines, and all Futures are asyncio futures. Asynchronous + generators became real asynchronous generators as well. + +- Prompt_toolkit uses type annotations (almost) everywhere. This should not + break any code, but its very helpful in many ways. + +There are some minor breaking changes: + +- The dialogs API had to change (see below). + + +Detecting the prompt_toolkit version +------------------------------------ + +Detecting whether version 3 is being used can be done as follows: + +.. code:: python + + from prompt_toolkit import __version__ as ptk_version + + PTK3 = ptk_version.startswith('3.') + + +Fixing calls to `get_event_loop` +-------------------------------- + +Every usage of ``get_event_loop`` has to be fixed. An easy way to do this is by +changing the imports like this: + +.. code:: python + + if PTK3: + from asyncio import get_event_loop + else: + from prompt_toolkit.eventloop import get_event_loop + +Notice that for prompt_toolkit 2.0, ``get_event_loop`` returns a prompt_toolkit +``EventLoop`` object. This is not an asyncio eventloop, but the API is +similar. + +There are some changes to the eventloop API: + ++-----------------------------------+--------------------------------------+ +| version 2.0 | version 3.0 (asyncio) | ++===================================+======================================+ +| loop.run_in_executor(callback) | loop.run_in_executor(None, callback) | ++-----------------------------------+--------------------------------------+ +| loop.call_from_executor(callback) | loop.call_soon_threadsafe(callback) | ++-----------------------------------+--------------------------------------+ + + +Running on top of asyncio +------------------------- + +For 2.0, you had tell prompt_toolkit to run on top of the asyncio event loop. +Now it's the default. So, you can simply remove the following two lines: + +.. code:: + + from prompt_toolkit.eventloop.defaults import use_asyncio_event_loop + use_asyncio_event_loop() + +There is a few little breaking changes though. The following: + +.. code:: + + # For 2.0 + result = await PromptSession().prompt('Say something: ', async_=True) + +has to be changed into: + +.. code:: + + # For 3.0 + result = await PromptSession().prompt_async('Say something: ') + +Further, it's impossible to call the `prompt()` function within an asyncio +application (within a coroutine), because it will try to run the event loop +again. In that case, always use `prompt_async()`. + + +Changes to the dialog functions +------------------------------- + +The original way of using dialog boxes looked like this: + +.. code:: python + + from prompt_toolkit.shortcuts import input_dialog + + result = input_dialog(title='...', text='...') + +Now, the dialog functions return a prompt_toolkit Application object. You have +to call either its ``run`` or ``run_async`` method to display the dialog. The +``async_`` parameter has been removed everywhere. + +.. code:: python + + if PTK3: + result = input_dialog(title='...', text='...').run() + else: + result = input_dialog(title='...', text='...') + + # Or + + if PTK3: + result = await input_dialog(title='...', text='...').run_async() + else: + result = await input_dialog(title='...', text='...', async_=True) diff --git a/docs/pages/upgrading/index.rst b/docs/pages/upgrading/index.rst new file mode 100644 index 0000000..b790a64 --- /dev/null +++ b/docs/pages/upgrading/index.rst @@ -0,0 +1,11 @@ +.. _upgrading: + +Upgrading +========= + +.. toctree:: + :caption: Contents: + :maxdepth: 1 + + 2.0 + 3.0 diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..beb1c31 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,5 @@ +Sphinx<7 +wcwidth<1 +pyperclip<2 +sphinx_copybutton>=0.5.0,<1.0.0 +sphinx-nefertiti>=0.2.1 diff --git a/examples/dialogs/button_dialog.py b/examples/dialogs/button_dialog.py new file mode 100755 index 0000000..7a99b9a --- /dev/null +++ b/examples/dialogs/button_dialog.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python +""" +Example of button dialog window. +""" +from prompt_toolkit.shortcuts import button_dialog + + +def main(): + result = button_dialog( + title="Button dialog example", + text="Are you sure?", + buttons=[("Yes", True), ("No", False), ("Maybe...", None)], + ).run() + + print(f"Result = {result}") + + +if __name__ == "__main__": + main() diff --git a/examples/dialogs/checkbox_dialog.py b/examples/dialogs/checkbox_dialog.py new file mode 100755 index 0000000..90be263 --- /dev/null +++ b/examples/dialogs/checkbox_dialog.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python +""" +Example of a checkbox-list-based dialog. +""" +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.shortcuts import checkboxlist_dialog, message_dialog +from prompt_toolkit.styles import Style + +results = checkboxlist_dialog( + title="CheckboxList dialog", + text="What would you like in your breakfast ?", + values=[ + ("eggs", "Eggs"), + ("bacon", HTML("<blue>Bacon</blue>")), + ("croissants", "20 Croissants"), + ("daily", "The breakfast of the day"), + ], + style=Style.from_dict( + { + "dialog": "bg:#cdbbb3", + "button": "bg:#bf99a4", + "checkbox": "#e8612c", + "dialog.body": "bg:#a9cfd0", + "dialog shadow": "bg:#c98982", + "frame.label": "#fcaca3", + "dialog.body label": "#fd8bb6", + } + ), +).run() +if results: + message_dialog( + title="Room service", + text="You selected: %s\nGreat choice sir !" % ",".join(results), + ).run() +else: + message_dialog("*starves*").run() diff --git a/examples/dialogs/input_dialog.py b/examples/dialogs/input_dialog.py new file mode 100755 index 0000000..6235265 --- /dev/null +++ b/examples/dialogs/input_dialog.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python +""" +Example of an input box dialog. +""" +from prompt_toolkit.shortcuts import input_dialog + + +def main(): + result = input_dialog( + title="Input dialog example", text="Please type your name:" + ).run() + + print(f"Result = {result}") + + +if __name__ == "__main__": + main() diff --git a/examples/dialogs/messagebox.py b/examples/dialogs/messagebox.py new file mode 100755 index 0000000..4642b84 --- /dev/null +++ b/examples/dialogs/messagebox.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python +""" +Example of a message box window. +""" +from prompt_toolkit.shortcuts import message_dialog + + +def main(): + message_dialog( + title="Example dialog window", + text="Do you want to continue?\nPress ENTER to quit.", + ).run() + + +if __name__ == "__main__": + main() diff --git a/examples/dialogs/password_dialog.py b/examples/dialogs/password_dialog.py new file mode 100755 index 0000000..39d7b9c --- /dev/null +++ b/examples/dialogs/password_dialog.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python +""" +Example of an password input dialog. +""" +from prompt_toolkit.shortcuts import input_dialog + + +def main(): + result = input_dialog( + title="Password dialog example", + text="Please type your password:", + password=True, + ).run() + + print(f"Result = {result}") + + +if __name__ == "__main__": + main() diff --git a/examples/dialogs/progress_dialog.py b/examples/dialogs/progress_dialog.py new file mode 100755 index 0000000..1fd3ffb --- /dev/null +++ b/examples/dialogs/progress_dialog.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python +""" +Example of a progress bar dialog. +""" +import os +import time + +from prompt_toolkit.shortcuts import progress_dialog + + +def worker(set_percentage, log_text): + """ + This worker function is called by `progress_dialog`. It will run in a + background thread. + + The `set_percentage` function can be used to update the progress bar, while + the `log_text` function can be used to log text in the logging window. + """ + percentage = 0 + for dirpath, dirnames, filenames in os.walk("../.."): + for f in filenames: + log_text(f"{dirpath} / {f}\n") + set_percentage(percentage + 1) + percentage += 2 + time.sleep(0.1) + + if percentage == 100: + break + if percentage == 100: + break + + # Show 100% for a second, before quitting. + set_percentage(100) + time.sleep(1) + + +def main(): + progress_dialog( + title="Progress dialog example", + text="As an examples, we walk through the filesystem and print " + "all directories", + run_callback=worker, + ).run() + + +if __name__ == "__main__": + main() diff --git a/examples/dialogs/radio_dialog.py b/examples/dialogs/radio_dialog.py new file mode 100755 index 0000000..94d80e2 --- /dev/null +++ b/examples/dialogs/radio_dialog.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python +""" +Example of a radio list box dialog. +""" +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.shortcuts import radiolist_dialog + + +def main(): + result = radiolist_dialog( + values=[ + ("red", "Red"), + ("green", "Green"), + ("blue", "Blue"), + ("orange", "Orange"), + ], + title="Radiolist dialog example", + text="Please select a color:", + ).run() + + print(f"Result = {result}") + + # With HTML. + result = radiolist_dialog( + values=[ + ("red", HTML('<style bg="red" fg="white">Red</style>')), + ("green", HTML('<style bg="green" fg="white">Green</style>')), + ("blue", HTML('<style bg="blue" fg="white">Blue</style>')), + ("orange", HTML('<style bg="orange" fg="white">Orange</style>')), + ], + title=HTML("Radiolist dialog example <reverse>with colors</reverse>"), + text="Please select a color:", + ).run() + + print(f"Result = {result}") + + +if __name__ == "__main__": + main() diff --git a/examples/dialogs/styled_messagebox.py b/examples/dialogs/styled_messagebox.py new file mode 100755 index 0000000..3f6fc53 --- /dev/null +++ b/examples/dialogs/styled_messagebox.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python +""" +Example of a style dialog window. +All dialog shortcuts take a `style` argument in order to apply a custom +styling. + +This also demonstrates that the `title` argument can be any kind of formatted +text. +""" +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.shortcuts import message_dialog +from prompt_toolkit.styles import Style + +# Custom color scheme. +example_style = Style.from_dict( + { + "dialog": "bg:#88ff88", + "dialog frame-label": "bg:#ffffff #000000", + "dialog.body": "bg:#000000 #00ff00", + "dialog shadow": "bg:#00aa00", + } +) + + +def main(): + message_dialog( + title=HTML( + '<style bg="blue" fg="white">Styled</style> ' + '<style fg="ansired">dialog</style> window' + ), + text="Do you want to continue?\nPress ENTER to quit.", + style=example_style, + ).run() + + +if __name__ == "__main__": + main() diff --git a/examples/dialogs/yes_no_dialog.py b/examples/dialogs/yes_no_dialog.py new file mode 100755 index 0000000..4b08dd6 --- /dev/null +++ b/examples/dialogs/yes_no_dialog.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python +""" +Example of confirmation (yes/no) dialog window. +""" +from prompt_toolkit.shortcuts import yes_no_dialog + + +def main(): + result = yes_no_dialog( + title="Yes/No dialog example", text="Do you want to confirm?" + ).run() + + print(f"Result = {result}") + + +if __name__ == "__main__": + main() diff --git a/examples/full-screen/ansi-art-and-textarea.py b/examples/full-screen/ansi-art-and-textarea.py new file mode 100755 index 0000000..c0a59fd --- /dev/null +++ b/examples/full-screen/ansi-art-and-textarea.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python + +from prompt_toolkit.application import Application +from prompt_toolkit.formatted_text import ANSI, HTML +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout import HSplit, Layout, VSplit, WindowAlign +from prompt_toolkit.widgets import Dialog, Label, TextArea + + +def main(): + # Key bindings. + kb = KeyBindings() + + @kb.add("c-c") + def _(event): + "Quit when control-c is pressed." + event.app.exit() + + text_area = TextArea(text="You can type here...") + dialog_body = HSplit( + [ + Label( + HTML("Press <reverse>control-c</reverse> to quit."), + align=WindowAlign.CENTER, + ), + VSplit( + [ + Label(PROMPT_TOOLKIT_LOGO, align=WindowAlign.CENTER), + text_area, + ], + ), + ] + ) + + application = Application( + layout=Layout( + container=Dialog( + title="ANSI Art demo - Art on the left, text area on the right", + body=dialog_body, + with_background=True, + ), + focused_element=text_area, + ), + full_screen=True, + mouse_support=True, + key_bindings=kb, + ) + application.run() + + +PROMPT_TOOLKIT_LOGO = ANSI( + """ +\x1b[48;2;0;0;0m \x1b[m +\x1b[48;2;0;0;0m \x1b[48;2;0;249;0m\x1b[38;2;0;0;0m▀\x1b[48;2;0;209;0m▀\x1b[48;2;0;207;0m\x1b[38;2;6;34;6m▀\x1b[48;2;0;66;0m\x1b[38;2;30;171;30m▀\x1b[48;2;0;169;0m\x1b[38;2;51;35;51m▀\x1b[48;2;0;248;0m\x1b[38;2;49;194;49m▀\x1b[48;2;0;111;0m\x1b[38;2;25;57;25m▀\x1b[48;2;140;195;140m\x1b[38;2;3;17;3m▀\x1b[48;2;30;171;30m\x1b[38;2;0;0;0m▀\x1b[48;2;0;0;0m \x1b[m +\x1b[48;2;0;0;0m \x1b[48;2;77;127;78m\x1b[38;2;118;227;108m▀\x1b[48;2;216;1;13m\x1b[38;2;49;221;57m▀\x1b[48;2;26;142;76m\x1b[38;2;108;146;165m▀\x1b[48;2;26;142;90m\x1b[38;2;209;197;114m▀▀\x1b[38;2;209;146;114m▀\x1b[48;2;26;128;90m\x1b[38;2;158;197;114m▀\x1b[48;2;58;210;70m\x1b[38;2;223;152;89m▀\x1b[48;2;232;139;44m\x1b[38;2;97;121;146m▀\x1b[48;2;233;139;45m\x1b[38;2;140;188;183m▀\x1b[48;2;231;139;44m\x1b[38;2;40;168;8m▀\x1b[48;2;228;140;44m\x1b[38;2;37;169;7m▀\x1b[48;2;227;140;44m\x1b[38;2;36;169;7m▀\x1b[48;2;211;142;41m\x1b[38;2;23;171;5m▀\x1b[48;2;86;161;17m\x1b[38;2;2;174;1m▀\x1b[48;2;0;175;0m \x1b[48;2;0;254;0m\x1b[38;2;190;119;190m▀\x1b[48;2;92;39;23m\x1b[38;2;125;50;114m▀\x1b[48;2;43;246;41m\x1b[38;2;49;10;165m▀\x1b[48;2;12;128;90m\x1b[38;2;209;197;114m▀\x1b[48;2;26;128;90m▀▀▀▀\x1b[48;2;26;128;76m▀\x1b[48;2;26;128;90m\x1b[38;2;209;247;114m▀▀\x1b[38;2;209;197;114m▀\x1b[48;2;26;128;76m\x1b[38;2;209;247;114m▀\x1b[48;2;26;128;90m▀▀▀\x1b[48;2;26;128;76m▀\x1b[48;2;26;128;90m▀▀\x1b[48;2;12;128;76m▀\x1b[48;2;12;113;90m\x1b[38;2;209;247;64m▀\x1b[38;2;209;247;114m▀\x1b[48;2;12;128;90m▀\x1b[48;2;12;113;90m▀\x1b[48;2;12;113;76m\x1b[38;2;209;247;64m▀\x1b[48;2;12;128;90m▀\x1b[48;2;12;113;90m▀\x1b[48;2;12;113;76m\x1b[38;2;209;247;114m▀\x1b[48;2;12;113;90m\x1b[38;2;209;247;64m▀\x1b[48;2;26;128;90m\x1b[38;2;151;129;163m▀\x1b[48;2;115;120;103m\x1b[38;2;62;83;227m▀\x1b[48;2;138;14;25m\x1b[38;2;104;106;160m▀\x1b[48;2;0;0;57m\x1b[38;2;0;0;0m▀\x1b[m +\x1b[48;2;249;147;8m\x1b[38;2;172;69;38m▀\x1b[48;2;197;202;10m\x1b[38;2;82;192;58m▀\x1b[48;2;248;124;45m\x1b[38;2;251;131;47m▀\x1b[48;2;248;124;44m▀\x1b[48;2;248;124;45m▀▀\x1b[48;2;248;124;44m▀\x1b[48;2;248;124;45m▀\x1b[48;2;248;125;45m\x1b[38;2;251;130;47m▀\x1b[48;2;248;124;45m\x1b[38;2;252;130;47m▀\x1b[48;2;248;125;45m\x1b[38;2;252;131;47m▀\x1b[38;2;252;130;47m▀\x1b[38;2;252;131;47m▀▀\x1b[48;2;249;125;45m\x1b[38;2;255;130;48m▀\x1b[48;2;233;127;42m\x1b[38;2;190;141;35m▀\x1b[48;2;57;163;10m\x1b[38;2;13;172;3m▀\x1b[48;2;0;176;0m\x1b[38;2;0;175;0m▀\x1b[48;2;7;174;1m\x1b[38;2;35;169;7m▀\x1b[48;2;178;139;32m\x1b[38;2;220;136;41m▀\x1b[48;2;252;124;45m\x1b[38;2;253;131;47m▀\x1b[48;2;248;125;45m\x1b[38;2;251;131;47m▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\x1b[48;2;248;125;44m▀\x1b[48;2;248;135;61m\x1b[38;2;251;132;48m▀\x1b[48;2;250;173;122m\x1b[38;2;251;133;50m▀\x1b[48;2;249;155;93m\x1b[38;2;251;132;49m▀\x1b[48;2;248;132;55m\x1b[38;2;251;132;48m▀\x1b[48;2;250;173;122m\x1b[38;2;251;134;51m▀\x1b[48;2;250;163;106m\x1b[38;2;251;134;50m▀\x1b[48;2;248;128;49m\x1b[38;2;251;132;47m▀\x1b[48;2;250;166;110m\x1b[38;2;251;135;52m▀\x1b[48;2;250;175;125m\x1b[38;2;251;136;54m▀\x1b[48;2;248;132;56m\x1b[38;2;251;132;48m▀\x1b[48;2;248;220;160m\x1b[38;2;105;247;172m▀\x1b[48;2;62;101;236m\x1b[38;2;11;207;160m▀\x1b[m +\x1b[48;2;138;181;197m\x1b[38;2;205;36;219m▀\x1b[48;2;177;211;200m\x1b[38;2;83;231;105m▀\x1b[48;2;242;113;40m\x1b[38;2;245;119;42m▀\x1b[48;2;243;113;41m▀\x1b[48;2;245;114;41m▀▀▀▀▀▀▀▀\x1b[38;2;245;119;43m▀▀▀\x1b[48;2;247;114;41m\x1b[38;2;246;119;43m▀\x1b[48;2;202;125;34m\x1b[38;2;143;141;25m▀\x1b[48;2;84;154;14m\x1b[38;2;97;152;17m▀\x1b[48;2;36;166;6m▀\x1b[48;2;139;140;23m\x1b[38;2;183;133;32m▀\x1b[48;2;248;114;41m\x1b[38;2;248;118;43m▀\x1b[48;2;245;115;41m\x1b[38;2;245;119;43m▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\x1b[38;2;245;119;42m▀\x1b[48;2;246;117;44m\x1b[38;2;246;132;62m▀\x1b[48;2;246;123;54m\x1b[38;2;249;180;138m▀\x1b[48;2;246;120;49m\x1b[38;2;247;157;102m▀\x1b[48;2;246;116;42m\x1b[38;2;246;127;54m▀\x1b[48;2;246;121;50m\x1b[38;2;248;174;128m▀\x1b[48;2;246;120;48m\x1b[38;2;248;162;110m▀\x1b[48;2;246;116;41m\x1b[38;2;245;122;47m▀\x1b[48;2;246;118;46m\x1b[38;2;248;161;108m▀\x1b[48;2;244;118;47m\x1b[38;2;248;171;123m▀\x1b[48;2;243;115;42m\x1b[38;2;246;127;54m▀\x1b[48;2;179;52;29m\x1b[38;2;86;152;223m▀\x1b[48;2;141;225;95m\x1b[38;2;247;146;130m▀\x1b[m +\x1b[48;2;50;237;108m\x1b[38;2;94;70;153m▀\x1b[48;2;206;221;133m\x1b[38;2;64;240;39m▀\x1b[48;2;233;100;36m\x1b[38;2;240;107;38m▀\x1b[48;2;114;56;22m\x1b[38;2;230;104;37m▀\x1b[48;2;24;20;10m\x1b[38;2;193;90;33m▀\x1b[48;2;21;19;9m\x1b[38;2;186;87;32m▀▀▀▀▀▀▀\x1b[38;2;186;87;33m▀▀▀\x1b[48;2;22;18;10m\x1b[38;2;189;86;33m▀\x1b[48;2;18;36;8m\x1b[38;2;135;107;24m▀\x1b[48;2;3;153;2m\x1b[38;2;5;171;1m▀\x1b[48;2;0;177;0m \x1b[48;2;4;158;2m\x1b[38;2;69;147;12m▀\x1b[48;2;19;45;8m\x1b[38;2;185;89;32m▀\x1b[48;2;22;17;10m\x1b[38;2;186;87;33m▀\x1b[48;2;21;19;9m▀▀▀▀▀▀▀▀\x1b[48;2;21;19;10m▀▀\x1b[48;2;21;19;9m▀▀▀▀\x1b[48;2;21;19;10m▀▀▀\x1b[38;2;186;87;32m▀▀\x1b[48;2;21;19;9m\x1b[38;2;186;87;33m▀\x1b[48;2;21;19;10m\x1b[38;2;186;87;32m▀▀\x1b[48;2;21;19;9m\x1b[38;2;186;87;33m▀\x1b[48;2;22;19;10m\x1b[38;2;191;89;33m▀\x1b[48;2;95;49;20m\x1b[38;2;226;103;37m▀\x1b[48;2;227;99;36m\x1b[38;2;241;109;39m▀\x1b[48;2;80;140;154m\x1b[38;2;17;240;92m▀\x1b[48;2;221;58;175m\x1b[38;2;71;14;245m▀\x1b[m +\x1b[48;2;195;38;42m\x1b[38;2;5;126;86m▀\x1b[48;2;139;230;67m\x1b[38;2;253;201;228m▀\x1b[48;2;208;82;30m\x1b[38;2;213;89;32m▀\x1b[48;2;42;26;12m\x1b[38;2;44;27;12m▀\x1b[48;2;9;14;7m\x1b[38;2;8;13;7m▀\x1b[48;2;11;15;8m\x1b[38;2;10;14;7m▀▀▀▀▀▀▀▀▀▀▀\x1b[48;2;11;12;8m\x1b[38;2;10;17;7m▀\x1b[48;2;7;71;5m\x1b[38;2;4;120;3m▀\x1b[48;2;1;164;1m\x1b[38;2;0;178;0m▀\x1b[48;2;4;118;3m\x1b[38;2;0;177;0m▀\x1b[48;2;5;108;3m\x1b[38;2;4;116;3m▀\x1b[48;2;7;75;5m\x1b[38;2;10;23;7m▀\x1b[48;2;10;33;7m\x1b[38;2;10;12;7m▀\x1b[48;2;11;13;8m\x1b[38;2;10;14;7m▀\x1b[48;2;11;14;8m▀\x1b[48;2;11;15;8m▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\x1b[48;2;10;14;7m\x1b[38;2;9;14;7m▀\x1b[48;2;30;21;10m\x1b[38;2;30;22;10m▀\x1b[48;2;195;79;29m\x1b[38;2;200;84;31m▀\x1b[48;2;205;228;23m\x1b[38;2;111;40;217m▀\x1b[48;2;9;217;69m\x1b[38;2;115;137;104m▀\x1b[m +\x1b[48;2;106;72;209m\x1b[38;2;151;183;253m▀\x1b[48;2;120;239;0m\x1b[38;2;25;2;162m▀\x1b[48;2;203;72;26m\x1b[38;2;206;77;28m▀\x1b[48;2;42;24;11m\x1b[38;2;42;25;11m▀\x1b[48;2;9;14;7m \x1b[48;2;11;15;8m \x1b[38;2;11;14;8m▀\x1b[48;2;11;13;8m\x1b[38;2;10;28;7m▀\x1b[48;2;9;36;6m\x1b[38;2;7;78;5m▀\x1b[48;2;2;153;1m\x1b[38;2;6;94;4m▀\x1b[48;2;0;178;0m\x1b[38;2;2;156;1m▀\x1b[48;2;0;175;0m\x1b[38;2;1;167;1m▀\x1b[48;2;0;177;0m\x1b[38;2;2;145;2m▀\x1b[48;2;2;147;2m\x1b[38;2;8;54;6m▀\x1b[48;2;9;38;6m\x1b[38;2;11;13;8m▀\x1b[48;2;11;13;8m\x1b[38;2;11;14;8m▀\x1b[48;2;11;15;8m \x1b[48;2;10;14;7m \x1b[48;2;29;20;10m\x1b[38;2;29;21;10m▀\x1b[48;2;190;69;25m\x1b[38;2;193;74;27m▀\x1b[48;2;136;91;148m\x1b[38;2;42;159;86m▀\x1b[48;2;89;85;149m\x1b[38;2;160;5;219m▀\x1b[m +\x1b[48;2;229;106;143m\x1b[38;2;40;239;187m▀\x1b[48;2;196;134;237m\x1b[38;2;6;11;95m▀\x1b[48;2;197;60;22m\x1b[38;2;201;67;24m▀\x1b[48;2;41;22;10m\x1b[38;2;41;23;11m▀\x1b[48;2;9;14;7m \x1b[48;2;11;15;8m \x1b[48;2;10;14;7m\x1b[38;2;11;15;8m▀▀\x1b[48;2;11;15;8m \x1b[38;2;11;14;8m▀\x1b[48;2;11;14;8m\x1b[38;2;11;16;7m▀\x1b[48;2;11;15;7m\x1b[38;2;7;79;5m▀\x1b[48;2;7;68;5m\x1b[38;2;1;164;1m▀\x1b[48;2;2;153;1m\x1b[38;2;0;176;0m▀\x1b[48;2;2;154;1m\x1b[38;2;0;175;0m▀\x1b[48;2;5;107;3m\x1b[38;2;1;171;1m▀\x1b[48;2;4;115;3m\x1b[38;2;5;105;3m▀\x1b[48;2;6;84;4m\x1b[38;2;11;18;7m▀\x1b[48;2;10;30;7m\x1b[38;2;11;13;8m▀\x1b[48;2;11;13;8m\x1b[38;2;11;15;8m▀\x1b[48;2;11;14;8m▀\x1b[48;2;11;15;8m \x1b[48;2;10;14;7m \x1b[48;2;29;19;9m\x1b[38;2;29;20;10m▀\x1b[48;2;185;58;22m\x1b[38;2;188;64;24m▀\x1b[48;2;68;241;49m\x1b[38;2;199;22;211m▀\x1b[48;2;133;139;8m\x1b[38;2;239;129;78m▀\x1b[m +\x1b[48;2;74;30;32m\x1b[38;2;163;185;76m▀\x1b[48;2;110;172;9m\x1b[38;2;177;1;123m▀\x1b[48;2;189;43;16m\x1b[38;2;193;52;19m▀\x1b[48;2;39;20;9m\x1b[38;2;40;21;10m▀\x1b[48;2;9;14;7m \x1b[48;2;11;15;8m \x1b[48;2;11;14;7m\x1b[38;2;11;15;8m▀\x1b[48;2;9;14;7m\x1b[38;2;11;14;8m▀\x1b[48;2;106;54;38m\x1b[38;2;31;24;15m▀\x1b[48;2;164;71;49m\x1b[38;2;24;20;12m▀\x1b[48;2;94;46;31m\x1b[38;2;8;14;7m▀\x1b[48;2;36;24;15m\x1b[38;2;9;14;7m▀\x1b[48;2;11;15;8m\x1b[38;2;11;14;7m▀\x1b[48;2;8;14;7m\x1b[38;2;11;15;8m▀\x1b[48;2;10;14;7m▀\x1b[48;2;11;15;8m \x1b[38;2;11;14;8m▀\x1b[48;2;11;14;8m\x1b[38;2;11;13;8m▀\x1b[48;2;11;13;8m\x1b[38;2;9;45;6m▀\x1b[48;2;10;19;7m\x1b[38;2;7;75;5m▀\x1b[48;2;6;83;4m\x1b[38;2;2;143;2m▀\x1b[48;2;2;156;1m\x1b[38;2;0;176;0m▀\x1b[48;2;0;177;0m\x1b[38;2;0;175;0m▀\x1b[38;2;3;134;2m▀\x1b[48;2;2;152;1m\x1b[38;2;9;46;6m▀\x1b[48;2;8;60;5m\x1b[38;2;11;13;8m▀\x1b[48;2;11;14;7m\x1b[38;2;11;14;8m▀\x1b[48;2;11;14;8m\x1b[38;2;11;15;8m▀\x1b[48;2;11;15;8m \x1b[48;2;10;14;7m \x1b[48;2;28;18;9m \x1b[48;2;177;43;16m\x1b[38;2;181;51;19m▀\x1b[48;2;93;35;236m\x1b[38;2;224;10;142m▀\x1b[48;2;72;51;52m\x1b[38;2;213;112;158m▀\x1b[m +\x1b[48;2;175;209;155m\x1b[38;2;7;131;221m▀\x1b[48;2;24;0;85m\x1b[38;2;44;86;152m▀\x1b[48;2;181;27;10m\x1b[38;2;185;35;13m▀\x1b[48;2;38;17;8m\x1b[38;2;39;18;9m▀\x1b[48;2;9;14;7m \x1b[48;2;11;15;8m \x1b[48;2;11;14;7m \x1b[48;2;9;14;7m \x1b[48;2;87;43;32m\x1b[38;2;114;54;39m▀\x1b[48;2;188;71;54m\x1b[38;2;211;82;59m▀\x1b[48;2;203;73;55m\x1b[38;2;204;80;57m▀\x1b[48;2;205;73;55m\x1b[38;2;178;71;51m▀\x1b[48;2;204;74;55m\x1b[38;2;119;52;37m▀\x1b[48;2;188;69;52m\x1b[38;2;54;29;19m▀\x1b[48;2;141;55;41m\x1b[38;2;16;17;9m▀\x1b[48;2;75;35;24m\x1b[38;2;8;14;7m▀\x1b[48;2;26;20;12m\x1b[38;2;10;14;7m▀\x1b[48;2;9;14;7m\x1b[38;2;11;14;7m▀\x1b[38;2;11;15;8m▀\x1b[48;2;11;14;7m▀\x1b[48;2;11;15;8m \x1b[38;2;11;14;8m▀\x1b[48;2;11;14;8m \x1b[48;2;11;13;8m\x1b[38;2;9;45;6m▀\x1b[48;2;10;23;7m\x1b[38;2;4;123;3m▀\x1b[48;2;7;75;5m\x1b[38;2;1;172;1m▀\x1b[48;2;6;84;4m\x1b[38;2;2;154;1m▀\x1b[48;2;4;114;3m\x1b[38;2;5;107;3m▀\x1b[48;2;5;103;4m\x1b[38;2;10;29;7m▀\x1b[48;2;10;23;7m\x1b[38;2;11;13;8m▀\x1b[48;2;11;14;8m\x1b[38;2;11;15;8m▀\x1b[48;2;11;15;8m \x1b[48;2;10;14;7m \x1b[48;2;27;16;8m\x1b[38;2;27;17;9m▀\x1b[48;2;170;27;10m\x1b[38;2;174;35;13m▀\x1b[48;2;118;117;199m\x1b[38;2;249;61;74m▀\x1b[48;2;10;219;61m\x1b[38;2;187;245;202m▀\x1b[m +\x1b[48;2;20;155;44m\x1b[38;2;86;54;110m▀\x1b[48;2;195;85;113m\x1b[38;2;214;171;227m▀\x1b[48;2;173;10;4m\x1b[38;2;177;19;7m▀\x1b[48;2;37;14;7m\x1b[38;2;37;16;8m▀\x1b[48;2;9;15;8m\x1b[38;2;9;14;7m▀\x1b[48;2;11;15;8m \x1b[38;2;11;14;7m▀\x1b[48;2;11;14;7m\x1b[38;2;15;17;9m▀\x1b[48;2;9;14;7m\x1b[38;2;50;29;20m▀\x1b[48;2;10;15;8m\x1b[38;2;112;47;36m▀\x1b[48;2;33;22;15m\x1b[38;2;170;61;48m▀\x1b[48;2;88;38;29m\x1b[38;2;197;66;53m▀\x1b[48;2;151;53;43m\x1b[38;2;201;67;53m▀\x1b[48;2;189;60;50m▀\x1b[48;2;198;60;51m\x1b[38;2;194;65;52m▀\x1b[38;2;160;56;44m▀\x1b[48;2;196;60;50m\x1b[38;2;99;40;30m▀\x1b[48;2;174;55;47m\x1b[38;2;41;24;16m▀\x1b[48;2;122;43;35m\x1b[38;2;12;15;8m▀\x1b[48;2;59;27;20m\x1b[38;2;8;14;7m▀\x1b[48;2;16;16;9m\x1b[38;2;10;14;7m▀\x1b[48;2;10;14;7m\x1b[38;2;11;15;8m▀\x1b[48;2;11;15;8m \x1b[38;2;11;14;8m▀\x1b[48;2;11;14;8m\x1b[38;2;11;12;8m▀\x1b[48;2;10;25;7m\x1b[38;2;7;79;5m▀\x1b[48;2;3;141;2m\x1b[38;2;1;174;1m▀\x1b[48;2;0;178;0m\x1b[38;2;1;169;1m▀\x1b[48;2;6;88;4m\x1b[38;2;8;56;6m▀\x1b[48;2;11;12;8m \x1b[48;2;11;14;8m\x1b[38;2;11;15;8m▀\x1b[48;2;11;15;8m \x1b[48;2;10;14;7m \x1b[48;2;26;15;8m\x1b[38;2;27;15;8m▀\x1b[48;2;162;12;5m\x1b[38;2;166;20;8m▀\x1b[48;2;143;168;130m\x1b[38;2;18;142;37m▀\x1b[48;2;240;96;105m\x1b[38;2;125;158;211m▀\x1b[m +\x1b[48;2;54;0;0m\x1b[38;2;187;22;0m▀\x1b[48;2;204;0;0m\x1b[38;2;128;208;0m▀\x1b[48;2;162;1;1m\x1b[38;2;168;3;1m▀\x1b[48;2;35;13;7m\x1b[38;2;36;13;7m▀\x1b[48;2;9;15;8m \x1b[48;2;11;15;8m \x1b[38;2;11;14;7m▀\x1b[38;2;9;14;7m▀\x1b[38;2;8;14;7m▀\x1b[48;2;10;14;7m\x1b[38;2;21;18;11m▀\x1b[48;2;7;13;6m\x1b[38;2;65;30;23m▀\x1b[48;2;12;16;9m\x1b[38;2;129;45;38m▀\x1b[48;2;57;29;23m\x1b[38;2;176;53;47m▀\x1b[48;2;148;49;44m\x1b[38;2;191;53;48m▀\x1b[48;2;187;52;48m\x1b[38;2;192;53;48m▀\x1b[48;2;186;51;47m\x1b[38;2;194;54;49m▀\x1b[48;2;182;52;47m\x1b[38;2;178;52;46m▀\x1b[48;2;59;27;21m\x1b[38;2;53;26;19m▀\x1b[48;2;8;14;7m \x1b[48;2;11;15;8m \x1b[48;2;11;14;8m\x1b[38;2;11;15;8m▀\x1b[48;2;11;12;8m\x1b[38;2;11;14;8m▀\x1b[48;2;10;30;7m\x1b[38;2;10;23;7m▀\x1b[48;2;5;110;3m\x1b[38;2;3;138;2m▀\x1b[48;2;2;149;2m\x1b[38;2;0;181;0m▀\x1b[48;2;6;92;4m\x1b[38;2;5;100;4m▀\x1b[48;2;11;13;8m \x1b[48;2;11;14;8m \x1b[48;2;11;15;8m \x1b[48;2;10;15;8m \x1b[48;2;25;14;7m\x1b[38;2;26;14;7m▀\x1b[48;2;152;2;1m\x1b[38;2;158;5;2m▀\x1b[48;2;6;0;0m\x1b[38;2;44;193;0m▀\x1b[48;2;108;0;0m\x1b[38;2;64;70;0m▀\x1b[m +\x1b[48;2;44;0;0m\x1b[38;2;177;0;0m▀\x1b[48;2;147;0;0m\x1b[38;2;71;0;0m▀\x1b[48;2;148;1;1m\x1b[38;2;155;1;1m▀\x1b[48;2;33;13;7m\x1b[38;2;34;13;7m▀\x1b[48;2;9;15;8m \x1b[48;2;11;15;8m \x1b[48;2;11;14;7m\x1b[38;2;11;15;8m▀\x1b[48;2;10;14;7m▀\x1b[48;2;9;14;7m▀\x1b[48;2;13;16;9m\x1b[38;2;11;14;7m▀\x1b[48;2;42;24;17m\x1b[38;2;9;14;7m▀\x1b[48;2;97;38;32m\x1b[38;2;10;15;8m▀\x1b[48;2;149;49;44m\x1b[38;2;30;21;14m▀\x1b[48;2;174;52;48m\x1b[38;2;79;34;28m▀\x1b[48;2;178;52;48m\x1b[38;2;136;45;40m▀\x1b[38;2;172;51;47m▀\x1b[48;2;173;52;48m\x1b[38;2;181;52;48m▀\x1b[48;2;147;47;42m\x1b[38;2;183;52;48m▀\x1b[48;2;94;35;30m\x1b[38;2;177;52;48m▀\x1b[48;2;25;19;12m\x1b[38;2;56;27;20m▀\x1b[48;2;10;14;7m\x1b[38;2;8;14;7m▀\x1b[48;2;11;12;8m\x1b[38;2;11;15;8m▀\x1b[48;2;10;23;7m\x1b[38;2;11;14;8m▀\x1b[48;2;7;76;5m\x1b[38;2;11;13;8m▀\x1b[48;2;2;152;1m\x1b[38;2;9;45;6m▀\x1b[48;2;0;177;0m\x1b[38;2;5;106;3m▀\x1b[48;2;0;178;0m\x1b[38;2;4;123;3m▀\x1b[48;2;1;168;1m\x1b[38;2;5;104;3m▀\x1b[48;2;8;53;6m\x1b[38;2;9;47;6m▀\x1b[48;2;11;12;8m\x1b[38;2;11;13;8m▀\x1b[48;2;11;15;8m \x1b[48;2;10;15;8m \x1b[48;2;24;14;7m\x1b[38;2;25;14;7m▀\x1b[48;2;140;2;1m\x1b[38;2;146;2;1m▀\x1b[48;2;219;0;0m\x1b[38;2;225;0;0m▀\x1b[48;2;126;0;0m\x1b[38;2;117;0;0m▀\x1b[m +\x1b[48;2;34;0;0m\x1b[38;2;167;0;0m▀\x1b[48;2;89;0;0m\x1b[38;2;14;0;0m▀\x1b[48;2;134;1;1m\x1b[38;2;141;1;1m▀\x1b[48;2;31;13;7m\x1b[38;2;32;13;7m▀\x1b[48;2;10;15;8m \x1b[48;2;11;15;8m \x1b[48;2;11;14;7m\x1b[38;2;11;15;8m▀\x1b[48;2;10;14;7m\x1b[38;2;11;14;7m▀\x1b[48;2;53;29;22m\x1b[38;2;10;14;7m▀\x1b[48;2;127;46;41m\x1b[38;2;20;18;11m▀\x1b[48;2;158;51;47m\x1b[38;2;57;28;22m▀\x1b[48;2;166;52;48m\x1b[38;2;113;42;36m▀\x1b[48;2;167;52;48m\x1b[38;2;156;50;46m▀\x1b[48;2;164;52;48m\x1b[38;2;171;52;48m▀\x1b[48;2;146;48;44m\x1b[38;2;172;52;48m▀\x1b[48;2;102;38;33m▀\x1b[48;2;50;26;19m\x1b[38;2;161;51;46m▀\x1b[48;2;17;17;10m\x1b[38;2;126;44;38m▀\x1b[48;2;8;14;7m\x1b[38;2;71;31;25m▀\x1b[48;2;10;14;7m\x1b[38;2;27;19;13m▀\x1b[48;2;11;13;8m\x1b[38;2;10;14;7m▀\x1b[48;2;9;40;6m\x1b[38;2;10;13;7m▀\x1b[48;2;4;119;3m\x1b[38;2;11;20;7m▀\x1b[48;2;1;168;1m\x1b[38;2;8;63;5m▀\x1b[48;2;0;177;0m\x1b[38;2;3;130;2m▀\x1b[48;2;0;175;0m\x1b[38;2;1;171;1m▀\x1b[48;2;1;174;1m\x1b[38;2;0;176;0m▀\x1b[48;2;1;175;1m\x1b[38;2;1;174;1m▀\x1b[48;2;0;177;0m\x1b[38;2;0;176;0m▀\x1b[48;2;3;134;2m\x1b[38;2;2;158;1m▀\x1b[48;2;10;21;7m\x1b[38;2;9;38;6m▀\x1b[48;2;11;14;8m\x1b[38;2;11;13;8m▀\x1b[48;2;11;15;8m \x1b[48;2;10;15;8m \x1b[48;2;23;14;7m \x1b[48;2;127;2;1m\x1b[38;2;133;2;1m▀\x1b[48;2;176;0;0m\x1b[38;2;213;0;0m▀\x1b[48;2;109;0;0m\x1b[38;2;100;0;0m▀\x1b[m +\x1b[48;2;24;0;0m\x1b[38;2;157;0;0m▀\x1b[48;2;32;0;0m\x1b[38;2;165;0;0m▀\x1b[48;2;121;1;1m\x1b[38;2;128;1;1m▀\x1b[48;2;28;13;7m\x1b[38;2;30;13;7m▀\x1b[48;2;10;15;8m \x1b[48;2;11;15;8m \x1b[48;2;11;14;7m \x1b[48;2;9;15;7m \x1b[48;2;88;41;34m\x1b[38;2;91;41;34m▀\x1b[48;2;145;51;47m\x1b[38;2;163;53;49m▀\x1b[48;2;107;42;36m\x1b[38;2;161;52;48m▀\x1b[48;2;58;29;22m\x1b[38;2;155;51;47m▀\x1b[48;2;21;18;11m\x1b[38;2;128;45;40m▀\x1b[48;2;9;14;7m\x1b[38;2;79;33;27m▀\x1b[38;2;33;21;15m▀\x1b[48;2;11;14;7m\x1b[38;2;12;15;8m▀\x1b[48;2;11;15;8m\x1b[38;2;9;14;7m▀\x1b[38;2;10;14;7m▀ \x1b[48;2;11;12;8m\x1b[38;2;11;14;8m▀\x1b[48;2;8;54;6m\x1b[38;2;10;28;7m▀\x1b[48;2;6;93;4m\x1b[38;2;4;125;3m▀\x1b[48;2;2;152;1m\x1b[38;2;0;175;0m▀\x1b[48;2;0;176;0m▀\x1b[48;2;0;175;0m\x1b[38;2;1;174;1m▀\x1b[48;2;0;177;0m\x1b[38;2;1;175;1m▀\x1b[48;2;0;175;0m▀▀\x1b[48;2;1;162;1m\x1b[38;2;0;176;0m▀\x1b[48;2;9;47;6m\x1b[38;2;6;95;4m▀\x1b[48;2;11;13;8m \x1b[48;2;11;15;8m\x1b[38;2;11;14;8m▀ \x1b[48;2;10;15;8m \x1b[48;2;21;13;7m\x1b[38;2;22;13;7m▀\x1b[48;2;114;2;1m\x1b[38;2;121;2;1m▀\x1b[48;2;164;0;0m\x1b[38;2;170;0;0m▀\x1b[48;2;127;0;0m\x1b[38;2;118;0;0m▀\x1b[m +\x1b[48;2;14;0;0m\x1b[38;2;147;0;0m▀\x1b[48;2;183;0;0m\x1b[38;2;108;0;0m▀\x1b[48;2;107;1;1m\x1b[38;2;114;1;1m▀\x1b[48;2;26;13;7m\x1b[38;2;27;13;7m▀\x1b[48;2;10;15;8m \x1b[48;2;11;15;8m \x1b[38;2;11;14;7m▀ \x1b[48;2;10;14;7m\x1b[38;2;43;27;20m▀\x1b[48;2;9;14;7m\x1b[38;2;42;25;18m▀\x1b[48;2;11;14;7m\x1b[38;2;14;16;9m▀\x1b[48;2;11;15;8m\x1b[38;2;9;14;7m▀\x1b[38;2;10;14;7m▀\x1b[38;2;11;14;7m▀ \x1b[48;2;11;12;8m \x1b[48;2;9;49;6m\x1b[38;2;8;64;5m▀\x1b[48;2;1;166;1m\x1b[38;2;1;159;1m▀\x1b[48;2;0;175;0m\x1b[38;2;1;171;1m▀ \x1b[48;2;1;159;1m\x1b[38;2;1;167;1m▀\x1b[48;2;7;79;5m\x1b[38;2;4;122;3m▀\x1b[48;2;2;144;2m\x1b[38;2;2;158;1m▀\x1b[48;2;0;158;1m\x1b[38;2;0;177;0m▀\x1b[48;2;7;44;6m\x1b[38;2;4;112;3m▀\x1b[48;2;9;12;7m\x1b[38;2;11;17;7m▀\x1b[48;2;9;14;7m\x1b[38;2;11;14;8m▀\x1b[38;2;11;15;8m▀▀▀▀▀▀▀▀▀▀▀\x1b[48;2;11;14;7m▀\x1b[48;2;11;15;8m \x1b[48;2;10;15;8m \x1b[48;2;20;13;7m\x1b[38;2;21;13;7m▀\x1b[48;2;102;2;1m\x1b[38;2;108;2;1m▀\x1b[48;2;121;0;0m\x1b[38;2;127;0;0m▀\x1b[48;2;146;0;0m\x1b[38;2;136;0;0m▀\x1b[m +\x1b[48;2;3;0;0m\x1b[38;2;137;0;0m▀\x1b[48;2;173;0;0m\x1b[38;2;50;0;0m▀\x1b[48;2;93;1;1m\x1b[38;2;100;1;1m▀\x1b[48;2;24;13;7m\x1b[38;2;25;13;7m▀\x1b[48;2;10;15;8m \x1b[48;2;11;15;8m \x1b[48;2;11;14;7m\x1b[38;2;11;15;8m▀▀\x1b[48;2;17;14;7m\x1b[38;2;11;14;8m▀\x1b[48;2;49;12;7m\x1b[38;2;9;24;7m▀\x1b[48;2;62;54;4m\x1b[38;2;8;133;2m▀\x1b[48;2;7;159;1m\x1b[38;2;2;176;0m▀\x1b[48;2;0;175;0m \x1b[48;2;1;172;1m\x1b[38;2;0;175;0m▀\x1b[48;2;1;159;1m\x1b[38;2;0;173;1m▀\x1b[48;2;46;122;19m\x1b[38;2;1;176;0m▀\x1b[48;2;122;63;45m\x1b[38;2;45;111;18m▀\x1b[48;2;135;52;49m\x1b[38;2;75;36;31m▀\x1b[48;2;135;53;49m\x1b[38;2;74;36;30m▀▀▀▀▀▀▀▀▀▀▀\x1b[48;2;136;53;49m\x1b[38;2;75;37;31m▀\x1b[48;2;119;49;45m\x1b[38;2;66;34;28m▀\x1b[48;2;25;20;13m\x1b[38;2;18;18;11m▀\x1b[48;2;10;14;7m \x1b[48;2;11;15;8m \x1b[48;2;10;15;8m \x1b[48;2;19;13;7m \x1b[48;2;89;2;1m\x1b[38;2;95;2;1m▀\x1b[48;2;77;0;0m\x1b[38;2;83;0;0m▀\x1b[48;2;128;0;0m\x1b[38;2;119;0;0m▀\x1b[m +\x1b[48;2;60;0;0m\x1b[38;2;126;0;0m▀\x1b[48;2;182;0;0m\x1b[38;2;249;0;0m▀\x1b[48;2;83;1;1m\x1b[38;2;87;1;1m▀\x1b[48;2;22;13;7m\x1b[38;2;23;13;7m▀\x1b[48;2;10;15;8m \x1b[48;2;11;15;8m \x1b[48;2;11;14;7m\x1b[38;2;16;14;7m▀\x1b[48;2;14;14;7m\x1b[38;2;42;13;7m▀\x1b[48;2;58;13;6m\x1b[38;2;95;11;5m▀\x1b[48;2;34;13;7m\x1b[38;2;100;11;5m▀\x1b[48;2;9;14;7m\x1b[38;2;21;17;7m▀\x1b[48;2;11;12;8m\x1b[38;2;8;55;6m▀\x1b[38;2;7;75;5m▀\x1b[38;2;8;65;5m▀\x1b[48;2;11;13;8m\x1b[38;2;9;41;6m▀\x1b[48;2;12;15;8m\x1b[38;2;60;37;28m▀\x1b[38;2;90;42;37m▀\x1b[38;2;88;42;36m▀▀▀▀▀▀▀▀▀▀▀▀\x1b[38;2;89;42;37m▀\x1b[38;2;78;39;33m▀\x1b[48;2;11;15;8m\x1b[38;2;20;18;11m▀\x1b[48;2;11;14;7m\x1b[38;2;10;14;7m▀\x1b[48;2;11;15;8m \x1b[48;2;10;15;8m \x1b[48;2;18;13;7m \x1b[48;2;78;2;1m\x1b[38;2;83;2;1m▀\x1b[48;2;196;0;0m\x1b[38;2;40;0;0m▀\x1b[48;2;217;0;0m\x1b[38;2;137;0;0m▀\x1b[m +\x1b[48;2;227;0;0m\x1b[38;2;16;0;0m▀\x1b[48;2;116;0;0m\x1b[38;2;21;0;0m▀\x1b[48;2;79;1;1m\x1b[38;2;81;1;1m▀\x1b[48;2;22;13;7m \x1b[48;2;10;15;8m \x1b[48;2;11;15;8m \x1b[38;2;10;15;8m▀\x1b[48;2;10;15;8m\x1b[38;2;21;14;7m▀\x1b[48;2;11;15;8m\x1b[38;2;14;14;7m▀\x1b[38;2;11;14;7m▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ \x1b[48;2;10;15;8m \x1b[48;2;17;13;7m\x1b[38;2;18;13;7m▀\x1b[48;2;75;2;1m\x1b[38;2;76;2;1m▀\x1b[48;2;97;0;0m\x1b[38;2;34;0;0m▀\x1b[48;2;76;0;0m\x1b[38;2;147;0;0m▀\x1b[m +\x1b[48;2;161;0;0m\x1b[38;2;183;0;0m▀\x1b[48;2;49;0;0m\x1b[38;2;211;0;0m▀\x1b[48;2;75;1;1m\x1b[38;2;77;1;1m▀\x1b[48;2;21;13;7m \x1b[48;2;10;15;8m \x1b[48;2;11;15;8m \x1b[48;2;10;15;8m \x1b[48;2;17;13;7m \x1b[48;2;71;2;1m\x1b[38;2;73;2;1m▀\x1b[48;2;253;0;0m\x1b[38;2;159;0;0m▀\x1b[48;2;191;0;0m\x1b[38;2;5;0;0m▀\x1b[m +\x1b[48;2;110;161;100m\x1b[38;2;116;0;0m▀\x1b[48;2;9;205;205m\x1b[38;2;192;0;0m▀\x1b[48;2;78;0;0m\x1b[38;2;77;1;0m▀\x1b[48;2;66;3;1m\x1b[38;2;30;11;6m▀\x1b[48;2;42;8;4m\x1b[38;2;9;15;8m▀\x1b[48;2;39;8;4m\x1b[38;2;10;15;8m▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\x1b[48;2;40;8;4m▀\x1b[48;2;39;8;4m▀▀▀▀▀▀▀\x1b[48;2;40;8;4m▀▀▀\x1b[48;2;39;8;4m▀\x1b[48;2;40;8;4m▀\x1b[48;2;39;8;4m▀\x1b[48;2;41;8;4m\x1b[38;2;9;15;8m▀\x1b[48;2;62;4;2m\x1b[38;2;24;13;7m▀\x1b[48;2;78;0;0m\x1b[38;2;74;1;1m▀\x1b[48;2;221;222;0m\x1b[38;2;59;0;0m▀\x1b[48;2;67;199;133m\x1b[38;2;85;0;0m▀\x1b[m +\x1b[48;2;0;0;0m\x1b[38;2;143;233;149m▀\x1b[48;2;108;184;254m\x1b[38;2;213;6;76m▀\x1b[48;2;197;183;82m\x1b[38;2;76;0;0m▀\x1b[48;2;154;157;0m▀\x1b[48;2;96;0;0m▀\x1b[48;2;253;0;0m▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\x1b[48;2;226;0;0m▀\x1b[48;2;255;127;255m▀\x1b[48;2;84;36;66m\x1b[38;2;64;247;251m▀\x1b[48;2;0;0;0m\x1b[38;2;18;76;210m▀\x1b[m +\x1b[48;2;0;0;0m \x1b[m +\x1b[48;2;0;0;0m \x1b[m +""" +) + +if __name__ == "__main__": + main() diff --git a/examples/full-screen/buttons.py b/examples/full-screen/buttons.py new file mode 100755 index 0000000..540194d --- /dev/null +++ b/examples/full-screen/buttons.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python +""" +A simple example of a few buttons and click handlers. +""" +from prompt_toolkit.application import Application +from prompt_toolkit.application.current import get_app +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.key_binding.bindings.focus import focus_next, focus_previous +from prompt_toolkit.layout import HSplit, Layout, VSplit +from prompt_toolkit.styles import Style +from prompt_toolkit.widgets import Box, Button, Frame, Label, TextArea + + +# Event handlers for all the buttons. +def button1_clicked(): + text_area.text = "Button 1 clicked" + + +def button2_clicked(): + text_area.text = "Button 2 clicked" + + +def button3_clicked(): + text_area.text = "Button 3 clicked" + + +def exit_clicked(): + get_app().exit() + + +# All the widgets for the UI. +button1 = Button("Button 1", handler=button1_clicked) +button2 = Button("Button 2", handler=button2_clicked) +button3 = Button("Button 3", handler=button3_clicked) +button4 = Button("Exit", handler=exit_clicked) +text_area = TextArea(focusable=True) + + +# Combine all the widgets in a UI. +# The `Box` object ensures that padding will be inserted around the containing +# widget. It adapts automatically, unless an explicit `padding` amount is given. +root_container = Box( + HSplit( + [ + Label(text="Press `Tab` to move the focus."), + VSplit( + [ + Box( + body=HSplit([button1, button2, button3, button4], padding=1), + padding=1, + style="class:left-pane", + ), + Box(body=Frame(text_area), padding=1, style="class:right-pane"), + ] + ), + ] + ), +) + +layout = Layout(container=root_container, focused_element=button1) + + +# Key bindings. +kb = KeyBindings() +kb.add("tab")(focus_next) +kb.add("s-tab")(focus_previous) + + +# Styling. +style = Style( + [ + ("left-pane", "bg:#888800 #000000"), + ("right-pane", "bg:#00aa00 #000000"), + ("button", "#000000"), + ("button-arrow", "#000000"), + ("button focused", "bg:#ff0000"), + ("text-area focused", "bg:#ff0000"), + ] +) + + +# Build a main application object. +application = Application(layout=layout, key_bindings=kb, style=style, full_screen=True) + + +def main(): + application.run() + + +if __name__ == "__main__": + main() diff --git a/examples/full-screen/calculator.py b/examples/full-screen/calculator.py new file mode 100755 index 0000000..1cb513f --- /dev/null +++ b/examples/full-screen/calculator.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python +""" +A simple example of a calculator program. +This could be used as inspiration for a REPL. +""" +from prompt_toolkit.application import Application +from prompt_toolkit.document import Document +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout.containers import HSplit, Window +from prompt_toolkit.layout.layout import Layout +from prompt_toolkit.styles import Style +from prompt_toolkit.widgets import SearchToolbar, TextArea + +help_text = """ +Type any expression (e.g. "4 + 4") followed by enter to execute. +Press Control-C to exit. +""" + + +def main(): + # The layout. + search_field = SearchToolbar() # For reverse search. + + output_field = TextArea(style="class:output-field", text=help_text) + input_field = TextArea( + height=1, + prompt=">>> ", + style="class:input-field", + multiline=False, + wrap_lines=False, + search_field=search_field, + ) + + container = HSplit( + [ + output_field, + Window(height=1, char="-", style="class:line"), + input_field, + search_field, + ] + ) + + # Attach accept handler to the input field. We do this by assigning the + # handler to the `TextArea` that we created earlier. it is also possible to + # pass it to the constructor of `TextArea`. + # NOTE: It's better to assign an `accept_handler`, rather then adding a + # custom ENTER key binding. This will automatically reset the input + # field and add the strings to the history. + def accept(buff): + # Evaluate "calculator" expression. + try: + output = f"\n\nIn: {input_field.text}\nOut: {eval(input_field.text)}" # Don't do 'eval' in real code! + except BaseException as e: + output = f"\n\n{e}" + new_text = output_field.text + output + + # Add text to output buffer. + output_field.buffer.document = Document( + text=new_text, cursor_position=len(new_text) + ) + + input_field.accept_handler = accept + + # The key bindings. + kb = KeyBindings() + + @kb.add("c-c") + @kb.add("c-q") + def _(event): + "Pressing Ctrl-Q or Ctrl-C will exit the user interface." + event.app.exit() + + # Style. + style = Style( + [ + ("output-field", "bg:#000044 #ffffff"), + ("input-field", "bg:#000000 #ffffff"), + ("line", "#004400"), + ] + ) + + # Run application. + application = Application( + layout=Layout(container, focused_element=input_field), + key_bindings=kb, + style=style, + mouse_support=True, + full_screen=True, + ) + + application.run() + + +if __name__ == "__main__": + main() diff --git a/examples/full-screen/dummy-app.py b/examples/full-screen/dummy-app.py new file mode 100755 index 0000000..7ea7506 --- /dev/null +++ b/examples/full-screen/dummy-app.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python +""" +This is the most simple example possible. +""" +from prompt_toolkit import Application + +app = Application(full_screen=False) +app.run() diff --git a/examples/full-screen/full-screen-demo.py b/examples/full-screen/full-screen-demo.py new file mode 100755 index 0000000..de7379a --- /dev/null +++ b/examples/full-screen/full-screen-demo.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python +""" +""" +from pygments.lexers.html import HtmlLexer + +from prompt_toolkit.application import Application +from prompt_toolkit.application.current import get_app +from prompt_toolkit.completion import WordCompleter +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.key_binding.bindings.focus import focus_next, focus_previous +from prompt_toolkit.layout.containers import Float, HSplit, VSplit +from prompt_toolkit.layout.dimension import D +from prompt_toolkit.layout.layout import Layout +from prompt_toolkit.layout.menus import CompletionsMenu +from prompt_toolkit.lexers import PygmentsLexer +from prompt_toolkit.styles import Style +from prompt_toolkit.widgets import ( + Box, + Button, + Checkbox, + Dialog, + Frame, + Label, + MenuContainer, + MenuItem, + ProgressBar, + RadioList, + TextArea, +) + + +def accept_yes(): + get_app().exit(result=True) + + +def accept_no(): + get_app().exit(result=False) + + +def do_exit(): + get_app().exit(result=False) + + +yes_button = Button(text="Yes", handler=accept_yes) +no_button = Button(text="No", handler=accept_no) +textfield = TextArea(lexer=PygmentsLexer(HtmlLexer)) +checkbox1 = Checkbox(text="Checkbox") +checkbox2 = Checkbox(text="Checkbox") + +radios = RadioList( + values=[ + ("Red", "red"), + ("Green", "green"), + ("Blue", "blue"), + ("Orange", "orange"), + ("Yellow", "yellow"), + ("Purple", "Purple"), + ("Brown", "Brown"), + ] +) + +animal_completer = WordCompleter( + [ + "alligator", + "ant", + "ape", + "bat", + "bear", + "beaver", + "bee", + "bison", + "butterfly", + "cat", + "chicken", + "crocodile", + "dinosaur", + "dog", + "dolphin", + "dove", + "duck", + "eagle", + "elephant", + "fish", + "goat", + "gorilla", + "kangaroo", + "leopard", + "lion", + "mouse", + "rabbit", + "rat", + "snake", + "spider", + "turkey", + "turtle", + ], + ignore_case=True, +) + +root_container = HSplit( + [ + VSplit( + [ + Frame(body=Label(text="Left frame\ncontent")), + Dialog(title="The custom window", body=Label("hello\ntest")), + textfield, + ], + height=D(), + ), + VSplit( + [ + Frame(body=ProgressBar(), title="Progress bar"), + Frame( + title="Checkbox list", + body=HSplit([checkbox1, checkbox2]), + ), + Frame(title="Radio list", body=radios), + ], + padding=1, + ), + Box( + body=VSplit([yes_button, no_button], align="CENTER", padding=3), + style="class:button-bar", + height=3, + ), + ] +) + +root_container = MenuContainer( + body=root_container, + menu_items=[ + MenuItem( + "File", + children=[ + MenuItem("New"), + MenuItem( + "Open", + children=[ + MenuItem("From file..."), + MenuItem("From URL..."), + MenuItem( + "Something else..", + children=[ + MenuItem("A"), + MenuItem("B"), + MenuItem("C"), + MenuItem("D"), + MenuItem("E"), + ], + ), + ], + ), + MenuItem("Save"), + MenuItem("Save as..."), + MenuItem("-", disabled=True), + MenuItem("Exit", handler=do_exit), + ], + ), + MenuItem( + "Edit", + children=[ + MenuItem("Undo"), + MenuItem("Cut"), + MenuItem("Copy"), + MenuItem("Paste"), + MenuItem("Delete"), + MenuItem("-", disabled=True), + MenuItem("Find"), + MenuItem("Find next"), + MenuItem("Replace"), + MenuItem("Go To"), + MenuItem("Select All"), + MenuItem("Time/Date"), + ], + ), + MenuItem("View", children=[MenuItem("Status Bar")]), + MenuItem("Info", children=[MenuItem("About")]), + ], + floats=[ + Float( + xcursor=True, + ycursor=True, + content=CompletionsMenu(max_height=16, scroll_offset=1), + ), + ], +) + +# Global key bindings. +bindings = KeyBindings() +bindings.add("tab")(focus_next) +bindings.add("s-tab")(focus_previous) + + +style = Style.from_dict( + { + "window.border": "#888888", + "shadow": "bg:#222222", + "menu-bar": "bg:#aaaaaa #888888", + "menu-bar.selected-item": "bg:#ffffff #000000", + "menu": "bg:#888888 #ffffff", + "menu.border": "#aaaaaa", + "window.border shadow": "#444444", + "focused button": "bg:#880000 #ffffff noinherit", + # Styling for Dialog widgets. + "button-bar": "bg:#aaaaff", + } +) + + +application = Application( + layout=Layout(root_container, focused_element=yes_button), + key_bindings=bindings, + style=style, + mouse_support=True, + full_screen=True, +) + + +def run(): + result = application.run() + print("You said: %r" % result) + + +if __name__ == "__main__": + run() diff --git a/examples/full-screen/hello-world.py b/examples/full-screen/hello-world.py new file mode 100755 index 0000000..b818018 --- /dev/null +++ b/examples/full-screen/hello-world.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python +""" +A simple example of a a text area displaying "Hello World!". +""" +from prompt_toolkit.application import Application +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout import Layout +from prompt_toolkit.widgets import Box, Frame, TextArea + +# Layout for displaying hello world. +# (The frame creates the border, the box takes care of the margin/padding.) +root_container = Box( + Frame( + TextArea( + text="Hello world!\nPress control-c to quit.", + width=40, + height=10, + ) + ), +) +layout = Layout(container=root_container) + + +# Key bindings. +kb = KeyBindings() + + +@kb.add("c-c") +def _(event): + "Quit when control-c is pressed." + event.app.exit() + + +# Build a main application object. +application = Application(layout=layout, key_bindings=kb, full_screen=True) + + +def main(): + application.run() + + +if __name__ == "__main__": + main() diff --git a/examples/full-screen/no-layout.py b/examples/full-screen/no-layout.py new file mode 100644 index 0000000..be5c6f8 --- /dev/null +++ b/examples/full-screen/no-layout.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python +""" +An empty full screen application without layout. +""" +from prompt_toolkit import Application + +Application(full_screen=True).run() diff --git a/examples/full-screen/pager.py b/examples/full-screen/pager.py new file mode 100755 index 0000000..799c834 --- /dev/null +++ b/examples/full-screen/pager.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python +""" +A simple application that shows a Pager application. +""" +from pygments.lexers.python import PythonLexer + +from prompt_toolkit.application import Application +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout.containers import HSplit, Window +from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.layout.dimension import LayoutDimension as D +from prompt_toolkit.layout.layout import Layout +from prompt_toolkit.lexers import PygmentsLexer +from prompt_toolkit.styles import Style +from prompt_toolkit.widgets import SearchToolbar, TextArea + +# Create one text buffer for the main content. + +_pager_py_path = __file__ + + +with open(_pager_py_path, "rb") as f: + text = f.read().decode("utf-8") + + +def get_statusbar_text(): + return [ + ("class:status", _pager_py_path + " - "), + ( + "class:status.position", + "{}:{}".format( + text_area.document.cursor_position_row + 1, + text_area.document.cursor_position_col + 1, + ), + ), + ("class:status", " - Press "), + ("class:status.key", "Ctrl-C"), + ("class:status", " to exit, "), + ("class:status.key", "/"), + ("class:status", " for searching."), + ] + + +search_field = SearchToolbar( + text_if_not_searching=[("class:not-searching", "Press '/' to start searching.")] +) + + +text_area = TextArea( + text=text, + read_only=True, + scrollbar=True, + line_numbers=True, + search_field=search_field, + lexer=PygmentsLexer(PythonLexer), +) + + +root_container = HSplit( + [ + # The top toolbar. + Window( + content=FormattedTextControl(get_statusbar_text), + height=D.exact(1), + style="class:status", + ), + # The main content. + text_area, + search_field, + ] +) + + +# Key bindings. +bindings = KeyBindings() + + +@bindings.add("c-c") +@bindings.add("q") +def _(event): + "Quit." + event.app.exit() + + +style = Style.from_dict( + { + "status": "reverse", + "status.position": "#aaaa00", + "status.key": "#ffaa00", + "not-searching": "#888888", + } +) + + +# create application. +application = Application( + layout=Layout(root_container, focused_element=text_area), + key_bindings=bindings, + enable_page_navigation_bindings=True, + mouse_support=True, + style=style, + full_screen=True, +) + + +def run(): + application.run() + + +if __name__ == "__main__": + run() diff --git a/examples/full-screen/scrollable-panes/simple-example.py b/examples/full-screen/scrollable-panes/simple-example.py new file mode 100644 index 0000000..a94274f --- /dev/null +++ b/examples/full-screen/scrollable-panes/simple-example.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python +""" +A simple example of a scrollable pane. +""" +from prompt_toolkit.application import Application +from prompt_toolkit.application.current import get_app +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.key_binding.bindings.focus import focus_next, focus_previous +from prompt_toolkit.layout import Dimension, HSplit, Layout, ScrollablePane +from prompt_toolkit.widgets import Frame, TextArea + + +def main(): + # Create a big layout of many text areas, then wrap them in a `ScrollablePane`. + root_container = Frame( + ScrollablePane( + HSplit( + [ + Frame(TextArea(text=f"label-{i}"), width=Dimension()) + for i in range(20) + ] + ) + ) + # ScrollablePane(HSplit([TextArea(text=f"label-{i}") for i in range(20)])) + ) + + layout = Layout(container=root_container) + + # Key bindings. + kb = KeyBindings() + + @kb.add("c-c") + def exit(event) -> None: + get_app().exit() + + kb.add("tab")(focus_next) + kb.add("s-tab")(focus_previous) + + # Create and run application. + application = Application(layout=layout, key_bindings=kb, full_screen=True) + application.run() + + +if __name__ == "__main__": + main() diff --git a/examples/full-screen/scrollable-panes/with-completion-menu.py b/examples/full-screen/scrollable-panes/with-completion-menu.py new file mode 100644 index 0000000..fba8d17 --- /dev/null +++ b/examples/full-screen/scrollable-panes/with-completion-menu.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python +""" +A simple example of a scrollable pane. +""" +from prompt_toolkit.application import Application +from prompt_toolkit.application.current import get_app +from prompt_toolkit.completion import WordCompleter +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.key_binding.bindings.focus import focus_next, focus_previous +from prompt_toolkit.layout import ( + CompletionsMenu, + Float, + FloatContainer, + HSplit, + Layout, + ScrollablePane, + VSplit, +) +from prompt_toolkit.widgets import Frame, Label, TextArea + + +def main(): + # Create a big layout of many text areas, then wrap them in a `ScrollablePane`. + root_container = VSplit( + [ + Label("<left column>"), + HSplit( + [ + Label("ScrollContainer Demo"), + Frame( + ScrollablePane( + HSplit( + [ + Frame( + TextArea( + text=f"label-{i}", + completer=animal_completer, + ) + ) + for i in range(20) + ] + ) + ), + ), + ] + ), + ] + ) + + root_container = FloatContainer( + root_container, + floats=[ + Float( + xcursor=True, + ycursor=True, + content=CompletionsMenu(max_height=16, scroll_offset=1), + ), + ], + ) + + layout = Layout(container=root_container) + + # Key bindings. + kb = KeyBindings() + + @kb.add("c-c") + def exit(event) -> None: + get_app().exit() + + kb.add("tab")(focus_next) + kb.add("s-tab")(focus_previous) + + # Create and run application. + application = Application( + layout=layout, key_bindings=kb, full_screen=True, mouse_support=True + ) + application.run() + + +animal_completer = WordCompleter( + [ + "alligator", + "ant", + "ape", + "bat", + "bear", + "beaver", + "bee", + "bison", + "butterfly", + "cat", + "chicken", + "crocodile", + "dinosaur", + "dog", + "dolphin", + "dove", + "duck", + "eagle", + "elephant", + "fish", + "goat", + "gorilla", + "kangaroo", + "leopard", + "lion", + "mouse", + "rabbit", + "rat", + "snake", + "spider", + "turkey", + "turtle", + ], + ignore_case=True, +) + + +if __name__ == "__main__": + main() diff --git a/examples/full-screen/simple-demos/alignment.py b/examples/full-screen/simple-demos/alignment.py new file mode 100755 index 0000000..b20b43d --- /dev/null +++ b/examples/full-screen/simple-demos/alignment.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python +""" +Demo of the different Window alignment options. +""" +from prompt_toolkit.application import Application +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout.containers import HSplit, Window, WindowAlign +from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.layout.layout import Layout + +LIPSUM = """Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas +quis interdum enim. Nam viverra, mauris et blandit malesuada, ante est bibendum +mauris, ac dignissim dui tellus quis ligula. Aenean condimentum leo at +dignissim placerat. In vel dictum ex, vulputate accumsan mi. Donec ut quam +placerat massa tempor elementum. Sed tristique mauris ac suscipit euismod. Ut +tempus vehicula augue non venenatis. Mauris aliquam velit turpis, nec congue +risus aliquam sit amet. Pellentesque blandit scelerisque felis, faucibus +consequat ante. Curabitur tempor tortor a imperdiet tincidunt. Nam sed justo +sit amet odio bibendum congue. Quisque varius ligula nec ligula gravida, sed +convallis augue faucibus. Nunc ornare pharetra bibendum. Praesent blandit ex +quis sodales maximus.""" + +# 1. The layout + +left_text = '\nLeft aligned text. - (Press "q" to quit)\n\n' + LIPSUM +center_text = "Centered text.\n\n" + LIPSUM +right_text = "Right aligned text.\n\n" + LIPSUM + + +body = HSplit( + [ + Window(FormattedTextControl(left_text), align=WindowAlign.LEFT), + Window(height=1, char="-"), + Window(FormattedTextControl(center_text), align=WindowAlign.CENTER), + Window(height=1, char="-"), + Window(FormattedTextControl(right_text), align=WindowAlign.RIGHT), + ] +) + + +# 2. Key bindings +kb = KeyBindings() + + +@kb.add("q") +def _(event): + "Quit application." + event.app.exit() + + +# 3. The `Application` +application = Application(layout=Layout(body), key_bindings=kb, full_screen=True) + + +def run(): + application.run() + + +if __name__ == "__main__": + run() diff --git a/examples/full-screen/simple-demos/autocompletion.py b/examples/full-screen/simple-demos/autocompletion.py new file mode 100755 index 0000000..bcbb594 --- /dev/null +++ b/examples/full-screen/simple-demos/autocompletion.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python +""" +An example of a BufferControl in a full screen layout that offers auto +completion. + +Important is to make sure that there is a `CompletionsMenu` in the layout, +otherwise the completions won't be visible. +""" +from prompt_toolkit.application import Application +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.completion import WordCompleter +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout.containers import Float, FloatContainer, HSplit, Window +from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl +from prompt_toolkit.layout.layout import Layout +from prompt_toolkit.layout.menus import CompletionsMenu + +# The completer. +animal_completer = WordCompleter( + [ + "alligator", + "ant", + "ape", + "bat", + "bear", + "beaver", + "bee", + "bison", + "butterfly", + "cat", + "chicken", + "crocodile", + "dinosaur", + "dog", + "dolphin", + "dove", + "duck", + "eagle", + "elephant", + "fish", + "goat", + "gorilla", + "kangaroo", + "leopard", + "lion", + "mouse", + "rabbit", + "rat", + "snake", + "spider", + "turkey", + "turtle", + ], + ignore_case=True, +) + + +# The layout +buff = Buffer(completer=animal_completer, complete_while_typing=True) + +body = FloatContainer( + content=HSplit( + [ + Window( + FormattedTextControl('Press "q" to quit.'), height=1, style="reverse" + ), + Window(BufferControl(buffer=buff)), + ] + ), + floats=[ + Float( + xcursor=True, + ycursor=True, + content=CompletionsMenu(max_height=16, scroll_offset=1), + ) + ], +) + + +# Key bindings +kb = KeyBindings() + + +@kb.add("q") +@kb.add("c-c") +def _(event): + "Quit application." + event.app.exit() + + +# The `Application` +application = Application(layout=Layout(body), key_bindings=kb, full_screen=True) + + +def run(): + application.run() + + +if __name__ == "__main__": + run() diff --git a/examples/full-screen/simple-demos/colorcolumn.py b/examples/full-screen/simple-demos/colorcolumn.py new file mode 100755 index 0000000..054aa44 --- /dev/null +++ b/examples/full-screen/simple-demos/colorcolumn.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python +""" +Colorcolumn example. +""" +from prompt_toolkit.application import Application +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout.containers import ColorColumn, HSplit, Window +from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl +from prompt_toolkit.layout.layout import Layout + +LIPSUM = """ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas +quis interdum enim. Nam viverra, mauris et blandit malesuada, ante est bibendum +mauris, ac dignissim dui tellus quis ligula. Aenean condimentum leo at +dignissim placerat. In vel dictum ex, vulputate accumsan mi. Donec ut quam +placerat massa tempor elementum. Sed tristique mauris ac suscipit euismod. Ut +tempus vehicula augue non venenatis. Mauris aliquam velit turpis, nec congue +risus aliquam sit amet. Pellentesque blandit scelerisque felis, faucibus +consequat ante. Curabitur tempor tortor a imperdiet tincidunt. Nam sed justo +sit amet odio bibendum congue. Quisque varius ligula nec ligula gravida, sed +convallis augue faucibus. Nunc ornare pharetra bibendum. Praesent blandit ex +quis sodales maximus.""" + +# Create text buffers. +buff = Buffer() +buff.text = LIPSUM + +# 1. The layout +color_columns = [ + ColorColumn(50), + ColorColumn(80, style="bg:#ff0000"), + ColorColumn(10, style="bg:#ff0000"), +] + +body = HSplit( + [ + Window(FormattedTextControl('Press "q" to quit.'), height=1, style="reverse"), + Window(BufferControl(buffer=buff), colorcolumns=color_columns), + ] +) + + +# 2. Key bindings +kb = KeyBindings() + + +@kb.add("q") +def _(event): + "Quit application." + event.app.exit() + + +# 3. The `Application` +application = Application(layout=Layout(body), key_bindings=kb, full_screen=True) + + +def run(): + application.run() + + +if __name__ == "__main__": + run() diff --git a/examples/full-screen/simple-demos/cursorcolumn-cursorline.py b/examples/full-screen/simple-demos/cursorcolumn-cursorline.py new file mode 100755 index 0000000..505b3ee --- /dev/null +++ b/examples/full-screen/simple-demos/cursorcolumn-cursorline.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python +""" +Cursorcolumn / cursorline example. +""" +from prompt_toolkit.application import Application +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout.containers import HSplit, Window +from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl +from prompt_toolkit.layout.layout import Layout + +LIPSUM = """ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas +quis interdum enim. Nam viverra, mauris et blandit malesuada, ante est bibendum +mauris, ac dignissim dui tellus quis ligula. Aenean condimentum leo at +dignissim placerat. In vel dictum ex, vulputate accumsan mi. Donec ut quam +placerat massa tempor elementum. Sed tristique mauris ac suscipit euismod. Ut +tempus vehicula augue non venenatis. Mauris aliquam velit turpis, nec congue +risus aliquam sit amet. Pellentesque blandit scelerisque felis, faucibus +consequat ante. Curabitur tempor tortor a imperdiet tincidunt. Nam sed justo +sit amet odio bibendum congue. Quisque varius ligula nec ligula gravida, sed +convallis augue faucibus. Nunc ornare pharetra bibendum. Praesent blandit ex +quis sodales maximus.""" + +# Create text buffers. Cursorcolumn/cursorline are mostly combined with an +# (editable) text buffers, where the user can move the cursor. + +buff = Buffer() +buff.text = LIPSUM + +# 1. The layout +body = HSplit( + [ + Window(FormattedTextControl('Press "q" to quit.'), height=1, style="reverse"), + Window(BufferControl(buffer=buff), cursorcolumn=True, cursorline=True), + ] +) + + +# 2. Key bindings +kb = KeyBindings() + + +@kb.add("q") +def _(event): + "Quit application." + event.app.exit() + + +# 3. The `Application` +application = Application(layout=Layout(body), key_bindings=kb, full_screen=True) + + +def run(): + application.run() + + +if __name__ == "__main__": + run() diff --git a/examples/full-screen/simple-demos/float-transparency.py b/examples/full-screen/simple-demos/float-transparency.py new file mode 100755 index 0000000..4dc38fc --- /dev/null +++ b/examples/full-screen/simple-demos/float-transparency.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python +""" +Example of the 'transparency' attribute of `Window' when used in a Float. +""" +from prompt_toolkit.application import Application +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout.containers import Float, FloatContainer, Window +from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.layout.layout import Layout +from prompt_toolkit.widgets import Frame + +LIPSUM = " ".join( + ( + """Lorem ipsum dolor sit amet, consectetur adipiscing elit. +Maecenas quis interdum enim. Nam viverra, mauris et blandit malesuada, ante est +bibendum mauris, ac dignissim dui tellus quis ligula. Aenean condimentum leo at +dignissim placerat. In vel dictum ex, vulputate accumsan mi. Donec ut quam +placerat massa tempor elementum. Sed tristique mauris ac suscipit euismod. Ut +tempus vehicula augue non venenatis. Mauris aliquam velit turpis, nec congue +risus aliquam sit amet. Pellentesque blandit scelerisque felis, faucibus +consequat ante. Curabitur tempor tortor a imperdiet tincidunt. Nam sed justo +sit amet odio bibendum congue. Quisque varius ligula nec ligula gravida, sed +convallis augue faucibus. Nunc ornare pharetra bibendum. Praesent blandit ex +quis sodales maximus. """ + * 100 + ).split() +) + + +# 1. The layout +left_text = HTML("<reverse>transparent=False</reverse>\n") +right_text = HTML("<reverse>transparent=True</reverse>") +quit_text = "Press 'q' to quit." + + +body = FloatContainer( + content=Window(FormattedTextControl(LIPSUM), wrap_lines=True), + floats=[ + # Important note: Wrapping the floating objects in a 'Frame' is + # only required for drawing the border around the + # floating text. We do it here to make the layout more + # obvious. + # Left float. + Float( + Frame(Window(FormattedTextControl(left_text), width=20, height=4)), + transparent=False, + left=0, + ), + # Right float. + Float( + Frame(Window(FormattedTextControl(right_text), width=20, height=4)), + transparent=True, + right=0, + ), + # Quit text. + Float( + Frame( + Window(FormattedTextControl(quit_text), width=18, height=1), + style="bg:#ff44ff #ffffff", + ), + top=1, + ), + ], +) + + +# 2. Key bindings +kb = KeyBindings() + + +@kb.add("q") +def _(event): + "Quit application." + event.app.exit() + + +# 3. The `Application` +application = Application(layout=Layout(body), key_bindings=kb, full_screen=True) + + +def run(): + application.run() + + +if __name__ == "__main__": + run() diff --git a/examples/full-screen/simple-demos/floats.py b/examples/full-screen/simple-demos/floats.py new file mode 100755 index 0000000..0d45be9 --- /dev/null +++ b/examples/full-screen/simple-demos/floats.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python +""" +Horizontal split example. +""" +from prompt_toolkit.application import Application +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout.containers import Float, FloatContainer, Window +from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.layout.layout import Layout +from prompt_toolkit.widgets import Frame + +LIPSUM = " ".join( + ( + """Lorem ipsum dolor sit amet, consectetur adipiscing elit. +Maecenas quis interdum enim. Nam viverra, mauris et blandit malesuada, ante est +bibendum mauris, ac dignissim dui tellus quis ligula. Aenean condimentum leo at +dignissim placerat. In vel dictum ex, vulputate accumsan mi. Donec ut quam +placerat massa tempor elementum. Sed tristique mauris ac suscipit euismod. Ut +tempus vehicula augue non venenatis. Mauris aliquam velit turpis, nec congue +risus aliquam sit amet. Pellentesque blandit scelerisque felis, faucibus +consequat ante. Curabitur tempor tortor a imperdiet tincidunt. Nam sed justo +sit amet odio bibendum congue. Quisque varius ligula nec ligula gravida, sed +convallis augue faucibus. Nunc ornare pharetra bibendum. Praesent blandit ex +quis sodales maximus. """ + * 100 + ).split() +) + + +# 1. The layout +left_text = "Floating\nleft" +right_text = "Floating\nright" +top_text = "Floating\ntop" +bottom_text = "Floating\nbottom" +center_text = "Floating\ncenter" +quit_text = "Press 'q' to quit." + + +body = FloatContainer( + content=Window(FormattedTextControl(LIPSUM), wrap_lines=True), + floats=[ + # Important note: Wrapping the floating objects in a 'Frame' is + # only required for drawing the border around the + # floating text. We do it here to make the layout more + # obvious. + # Left float. + Float( + Frame( + Window(FormattedTextControl(left_text), width=10, height=2), + style="bg:#44ffff #ffffff", + ), + left=0, + ), + # Right float. + Float( + Frame( + Window(FormattedTextControl(right_text), width=10, height=2), + style="bg:#44ffff #ffffff", + ), + right=0, + ), + # Bottom float. + Float( + Frame( + Window(FormattedTextControl(bottom_text), width=10, height=2), + style="bg:#44ffff #ffffff", + ), + bottom=0, + ), + # Top float. + Float( + Frame( + Window(FormattedTextControl(top_text), width=10, height=2), + style="bg:#44ffff #ffffff", + ), + top=0, + ), + # Center float. + Float( + Frame( + Window(FormattedTextControl(center_text), width=10, height=2), + style="bg:#44ffff #ffffff", + ) + ), + # Quit text. + Float( + Frame( + Window(FormattedTextControl(quit_text), width=18, height=1), + style="bg:#ff44ff #ffffff", + ), + top=6, + ), + ], +) + + +# 2. Key bindings +kb = KeyBindings() + + +@kb.add("q") +def _(event): + "Quit application." + event.app.exit() + + +# 3. The `Application` +application = Application(layout=Layout(body), key_bindings=kb, full_screen=True) + + +def run(): + application.run() + + +if __name__ == "__main__": + run() diff --git a/examples/full-screen/simple-demos/focus.py b/examples/full-screen/simple-demos/focus.py new file mode 100755 index 0000000..9fe9b8f --- /dev/null +++ b/examples/full-screen/simple-demos/focus.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python +""" +Demonstration of how to programmatically focus a certain widget. +""" +from prompt_toolkit.application import Application +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.document import Document +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout.containers import HSplit, VSplit, Window +from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl +from prompt_toolkit.layout.layout import Layout + +# 1. The layout +top_text = ( + "Focus example.\n" + "[q] Quit [a] Focus left top [b] Right top [c] Left bottom [d] Right bottom." +) + +LIPSUM = """Lorem ipsum dolor sit amet, consectetur adipiscing elit. +Maecenas quis interdum enim. Nam viverra, mauris et blandit malesuada, ante est +bibendum mauris, ac dignissim dui tellus quis ligula. Aenean condimentum leo at +dignissim placerat. In vel dictum ex, vulputate accumsan mi. Donec ut quam +placerat massa tempor elementum. Sed tristique mauris ac suscipit euismod. Ut +tempus vehicula augue non venenatis. Mauris aliquam velit turpis, nec congue +risus aliquam sit amet. Pellentesque blandit scelerisque felis, faucibus +consequat ante. Curabitur tempor tortor a imperdiet tincidunt. Nam sed justo +sit amet odio bibendum congue. Quisque varius ligula nec ligula gravida, sed +convallis augue faucibus. Nunc ornare pharetra bibendum. Praesent blandit ex +quis sodales maximus. """ + + +left_top = Window(BufferControl(Buffer(document=Document(LIPSUM)))) +left_bottom = Window(BufferControl(Buffer(document=Document(LIPSUM)))) +right_top = Window(BufferControl(Buffer(document=Document(LIPSUM)))) +right_bottom = Window(BufferControl(Buffer(document=Document(LIPSUM)))) + + +body = HSplit( + [ + Window(FormattedTextControl(top_text), height=2, style="reverse"), + Window(height=1, char="-"), # Horizontal line in the middle. + VSplit([left_top, Window(width=1, char="|"), right_top]), + Window(height=1, char="-"), # Horizontal line in the middle. + VSplit([left_bottom, Window(width=1, char="|"), right_bottom]), + ] +) + + +# 2. Key bindings +kb = KeyBindings() + + +@kb.add("q") +def _(event): + "Quit application." + event.app.exit() + + +@kb.add("a") +def _(event): + event.app.layout.focus(left_top) + + +@kb.add("b") +def _(event): + event.app.layout.focus(right_top) + + +@kb.add("c") +def _(event): + event.app.layout.focus(left_bottom) + + +@kb.add("d") +def _(event): + event.app.layout.focus(right_bottom) + + +@kb.add("tab") +def _(event): + event.app.layout.focus_next() + + +@kb.add("s-tab") +def _(event): + event.app.layout.focus_previous() + + +# 3. The `Application` +application = Application(layout=Layout(body), key_bindings=kb, full_screen=True) + + +def run(): + application.run() + + +if __name__ == "__main__": + run() diff --git a/examples/full-screen/simple-demos/horizontal-align.py b/examples/full-screen/simple-demos/horizontal-align.py new file mode 100755 index 0000000..bb0de12 --- /dev/null +++ b/examples/full-screen/simple-demos/horizontal-align.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python +""" +Horizontal align demo with HSplit. +""" +from prompt_toolkit.application import Application +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout.containers import ( + HorizontalAlign, + HSplit, + VerticalAlign, + VSplit, + Window, + WindowAlign, +) +from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.layout.layout import Layout +from prompt_toolkit.widgets import Frame + +TITLE = HTML( + """ <u>HSplit HorizontalAlign</u> example. + Press <b>'q'</b> to quit.""" +) + +LIPSUM = """\ +Lorem ipsum dolor +sit amet, consectetur +adipiscing elit. +Maecenas quis +interdum enim.""" + + +# 1. The layout +body = HSplit( + [ + Frame( + Window(FormattedTextControl(TITLE), height=2), style="bg:#88ff88 #000000" + ), + HSplit( + [ + # Left alignment. + VSplit( + [ + Window( + FormattedTextControl(HTML("<u>LEFT</u>")), + width=10, + ignore_content_width=True, + style="bg:#ff3333 ansiblack", + align=WindowAlign.CENTER, + ), + VSplit( + [ + Window( + FormattedTextControl(LIPSUM), + height=4, + style="bg:#444488", + ), + Window( + FormattedTextControl(LIPSUM), + height=4, + style="bg:#444488", + ), + Window( + FormattedTextControl(LIPSUM), + height=4, + style="bg:#444488", + ), + ], + padding=1, + padding_style="bg:#888888", + align=HorizontalAlign.LEFT, + height=5, + padding_char="|", + ), + ] + ), + # Center alignment. + VSplit( + [ + Window( + FormattedTextControl(HTML("<u>CENTER</u>")), + width=10, + ignore_content_width=True, + style="bg:#ff3333 ansiblack", + align=WindowAlign.CENTER, + ), + VSplit( + [ + Window( + FormattedTextControl(LIPSUM), + height=4, + style="bg:#444488", + ), + Window( + FormattedTextControl(LIPSUM), + height=4, + style="bg:#444488", + ), + Window( + FormattedTextControl(LIPSUM), + height=4, + style="bg:#444488", + ), + ], + padding=1, + padding_style="bg:#888888", + align=HorizontalAlign.CENTER, + height=5, + padding_char="|", + ), + ] + ), + # Right alignment. + VSplit( + [ + Window( + FormattedTextControl(HTML("<u>RIGHT</u>")), + width=10, + ignore_content_width=True, + style="bg:#ff3333 ansiblack", + align=WindowAlign.CENTER, + ), + VSplit( + [ + Window( + FormattedTextControl(LIPSUM), + height=4, + style="bg:#444488", + ), + Window( + FormattedTextControl(LIPSUM), + height=4, + style="bg:#444488", + ), + Window( + FormattedTextControl(LIPSUM), + height=4, + style="bg:#444488", + ), + ], + padding=1, + padding_style="bg:#888888", + align=HorizontalAlign.RIGHT, + height=5, + padding_char="|", + ), + ] + ), + # Justify + VSplit( + [ + Window( + FormattedTextControl(HTML("<u>JUSTIFY</u>")), + width=10, + ignore_content_width=True, + style="bg:#ff3333 ansiblack", + align=WindowAlign.CENTER, + ), + VSplit( + [ + Window( + FormattedTextControl(LIPSUM), style="bg:#444488" + ), + Window( + FormattedTextControl(LIPSUM), style="bg:#444488" + ), + Window( + FormattedTextControl(LIPSUM), style="bg:#444488" + ), + ], + padding=1, + padding_style="bg:#888888", + align=HorizontalAlign.JUSTIFY, + height=5, + padding_char="|", + ), + ] + ), + ], + padding=1, + padding_style="bg:#ff3333 #ffffff", + padding_char=".", + align=VerticalAlign.TOP, + ), + ] +) + + +# 2. Key bindings +kb = KeyBindings() + + +@kb.add("q") +def _(event): + "Quit application." + event.app.exit() + + +# 3. The `Application` +application = Application(layout=Layout(body), key_bindings=kb, full_screen=True) + + +def run(): + application.run() + + +if __name__ == "__main__": + run() diff --git a/examples/full-screen/simple-demos/horizontal-split.py b/examples/full-screen/simple-demos/horizontal-split.py new file mode 100755 index 0000000..0427e67 --- /dev/null +++ b/examples/full-screen/simple-demos/horizontal-split.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python +""" +Horizontal split example. +""" +from prompt_toolkit.application import Application +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout.containers import HSplit, Window +from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.layout.layout import Layout + +# 1. The layout +left_text = "\nVertical-split example. Press 'q' to quit.\n\n(top pane.)" +right_text = "\n(bottom pane.)" + + +body = HSplit( + [ + Window(FormattedTextControl(left_text)), + Window(height=1, char="-"), # Horizontal line in the middle. + Window(FormattedTextControl(right_text)), + ] +) + + +# 2. Key bindings +kb = KeyBindings() + + +@kb.add("q") +def _(event): + "Quit application." + event.app.exit() + + +# 3. The `Application` +application = Application(layout=Layout(body), key_bindings=kb, full_screen=True) + + +def run(): + application.run() + + +if __name__ == "__main__": + run() diff --git a/examples/full-screen/simple-demos/line-prefixes.py b/examples/full-screen/simple-demos/line-prefixes.py new file mode 100755 index 0000000..b52cb48 --- /dev/null +++ b/examples/full-screen/simple-demos/line-prefixes.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python +""" +An example of a BufferControl in a full screen layout that offers auto +completion. + +Important is to make sure that there is a `CompletionsMenu` in the layout, +otherwise the completions won't be visible. +""" +from prompt_toolkit.application import Application +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.filters import Condition +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout.containers import Float, FloatContainer, HSplit, Window +from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl +from prompt_toolkit.layout.layout import Layout +from prompt_toolkit.layout.menus import CompletionsMenu + +LIPSUM = """ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas +quis interdum enim. Nam viverra, mauris et blandit malesuada, ante est bibendum +mauris, ac dignissim dui tellus quis ligula. Aenean condimentum leo at +dignissim placerat. In vel dictum ex, vulputate accumsan mi. Donec ut quam +placerat massa tempor elementum. Sed tristique mauris ac suscipit euismod. Ut +tempus vehicula augue non venenatis. Mauris aliquam velit turpis, nec congue +risus aliquam sit amet. Pellentesque blandit scelerisque felis, faucibus +consequat ante. Curabitur tempor tortor a imperdiet tincidunt. Nam sed justo +sit amet odio bibendum congue. Quisque varius ligula nec ligula gravida, sed +convallis augue faucibus. Nunc ornare pharetra bibendum. Praesent blandit ex +quis sodales maximus.""" + + +def get_line_prefix(lineno, wrap_count): + if wrap_count == 0: + return HTML('[%s] <style bg="orange" fg="black">--></style> ') % lineno + + text = str(lineno) + "-" + "*" * (lineno // 2) + ": " + return HTML('[%s.%s] <style bg="ansigreen" fg="ansiblack">%s</style>') % ( + lineno, + wrap_count, + text, + ) + + +# Global wrap lines flag. +wrap_lines = True + + +# The layout +buff = Buffer(complete_while_typing=True) +buff.text = LIPSUM + + +body = FloatContainer( + content=HSplit( + [ + Window( + FormattedTextControl( + 'Press "q" to quit. Press "w" to enable/disable wrapping.' + ), + height=1, + style="reverse", + ), + Window( + BufferControl(buffer=buff), + get_line_prefix=get_line_prefix, + wrap_lines=Condition(lambda: wrap_lines), + ), + ] + ), + floats=[ + Float( + xcursor=True, + ycursor=True, + content=CompletionsMenu(max_height=16, scroll_offset=1), + ) + ], +) + + +# Key bindings +kb = KeyBindings() + + +@kb.add("q") +@kb.add("c-c") +def _(event): + "Quit application." + event.app.exit() + + +@kb.add("w") +def _(event): + "Disable/enable wrapping." + global wrap_lines + wrap_lines = not wrap_lines + + +# The `Application` +application = Application( + layout=Layout(body), key_bindings=kb, full_screen=True, mouse_support=True +) + + +def run(): + application.run() + + +if __name__ == "__main__": + run() diff --git a/examples/full-screen/simple-demos/margins.py b/examples/full-screen/simple-demos/margins.py new file mode 100755 index 0000000..467492d --- /dev/null +++ b/examples/full-screen/simple-demos/margins.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python +""" +Example of Window margins. + +This is mainly used for displaying line numbers and scroll bars, but it could +be used to display any other kind of information as well. +""" +from prompt_toolkit.application import Application +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout.containers import HSplit, Window +from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl +from prompt_toolkit.layout.layout import Layout +from prompt_toolkit.layout.margins import NumberedMargin, ScrollbarMargin + +LIPSUM = ( + """ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas +quis interdum enim. Nam viverra, mauris et blandit malesuada, ante est bibendum +mauris, ac dignissim dui tellus quis ligula. Aenean condimentum leo at +dignissim placerat. In vel dictum ex, vulputate accumsan mi. Donec ut quam +placerat massa tempor elementum. Sed tristique mauris ac suscipit euismod. Ut +tempus vehicula augue non venenatis. Mauris aliquam velit turpis, nec congue +risus aliquam sit amet. Pellentesque blandit scelerisque felis, faucibus +consequat ante. Curabitur tempor tortor a imperdiet tincidunt. Nam sed justo +sit amet odio bibendum congue. Quisque varius ligula nec ligula gravida, sed +convallis augue faucibus. Nunc ornare pharetra bibendum. Praesent blandit ex +quis sodales maximus.""" + * 40 +) + +# Create text buffers. The margins will update if you scroll up or down. + +buff = Buffer() +buff.text = LIPSUM + +# 1. The layout +body = HSplit( + [ + Window(FormattedTextControl('Press "q" to quit.'), height=1, style="reverse"), + Window( + BufferControl(buffer=buff), + # Add margins. + left_margins=[NumberedMargin(), ScrollbarMargin()], + right_margins=[ScrollbarMargin(), ScrollbarMargin()], + ), + ] +) + + +# 2. Key bindings +kb = KeyBindings() + + +@kb.add("q") +@kb.add("c-c") +def _(event): + "Quit application." + event.app.exit() + + +# 3. The `Application` +application = Application(layout=Layout(body), key_bindings=kb, full_screen=True) + + +def run(): + application.run() + + +if __name__ == "__main__": + run() diff --git a/examples/full-screen/simple-demos/vertical-align.py b/examples/full-screen/simple-demos/vertical-align.py new file mode 100755 index 0000000..1475d71 --- /dev/null +++ b/examples/full-screen/simple-demos/vertical-align.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python +""" +Vertical align demo with VSplit. +""" +from prompt_toolkit.application import Application +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout.containers import ( + HSplit, + VerticalAlign, + VSplit, + Window, + WindowAlign, +) +from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.layout.layout import Layout +from prompt_toolkit.widgets import Frame + +TITLE = HTML( + """ <u>VSplit VerticalAlign</u> example. + Press <b>'q'</b> to quit.""" +) + +LIPSUM = """ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas +quis interdum enim. Nam viverra, mauris et blandit malesuada, ante est bibendum +mauris, ac dignissim dui tellus quis ligula. Aenean condimentum leo at +dignissim placerat.""" + +# 1. The layout +body = HSplit( + [ + Frame( + Window(FormattedTextControl(TITLE), height=2), style="bg:#88ff88 #000000" + ), + VSplit( + [ + Window( + FormattedTextControl(HTML(" <u>VerticalAlign.TOP</u>")), + height=4, + ignore_content_width=True, + style="bg:#ff3333 #000000 bold", + align=WindowAlign.CENTER, + ), + Window( + FormattedTextControl(HTML(" <u>VerticalAlign.CENTER</u>")), + height=4, + ignore_content_width=True, + style="bg:#ff3333 #000000 bold", + align=WindowAlign.CENTER, + ), + Window( + FormattedTextControl(HTML(" <u>VerticalAlign.BOTTOM</u>")), + height=4, + ignore_content_width=True, + style="bg:#ff3333 #000000 bold", + align=WindowAlign.CENTER, + ), + Window( + FormattedTextControl(HTML(" <u>VerticalAlign.JUSTIFY</u>")), + height=4, + ignore_content_width=True, + style="bg:#ff3333 #000000 bold", + align=WindowAlign.CENTER, + ), + ], + height=1, + padding=1, + padding_style="bg:#ff3333", + ), + VSplit( + [ + # Top alignment. + HSplit( + [ + Window( + FormattedTextControl(LIPSUM), height=4, style="bg:#444488" + ), + Window( + FormattedTextControl(LIPSUM), height=4, style="bg:#444488" + ), + Window( + FormattedTextControl(LIPSUM), height=4, style="bg:#444488" + ), + ], + padding=1, + padding_style="bg:#888888", + align=VerticalAlign.TOP, + padding_char="~", + ), + # Center alignment. + HSplit( + [ + Window( + FormattedTextControl(LIPSUM), height=4, style="bg:#444488" + ), + Window( + FormattedTextControl(LIPSUM), height=4, style="bg:#444488" + ), + Window( + FormattedTextControl(LIPSUM), height=4, style="bg:#444488" + ), + ], + padding=1, + padding_style="bg:#888888", + align=VerticalAlign.CENTER, + padding_char="~", + ), + # Bottom alignment. + HSplit( + [ + Window( + FormattedTextControl(LIPSUM), height=4, style="bg:#444488" + ), + Window( + FormattedTextControl(LIPSUM), height=4, style="bg:#444488" + ), + Window( + FormattedTextControl(LIPSUM), height=4, style="bg:#444488" + ), + ], + padding=1, + padding_style="bg:#888888", + align=VerticalAlign.BOTTOM, + padding_char="~", + ), + # Justify + HSplit( + [ + Window(FormattedTextControl(LIPSUM), style="bg:#444488"), + Window(FormattedTextControl(LIPSUM), style="bg:#444488"), + Window(FormattedTextControl(LIPSUM), style="bg:#444488"), + ], + padding=1, + padding_style="bg:#888888", + align=VerticalAlign.JUSTIFY, + padding_char="~", + ), + ], + padding=1, + padding_style="bg:#ff3333 #ffffff", + padding_char=".", + ), + ] +) + + +# 2. Key bindings +kb = KeyBindings() + + +@kb.add("q") +def _(event): + "Quit application." + event.app.exit() + + +# 3. The `Application` +application = Application(layout=Layout(body), key_bindings=kb, full_screen=True) + + +def run(): + application.run() + + +if __name__ == "__main__": + run() diff --git a/examples/full-screen/simple-demos/vertical-split.py b/examples/full-screen/simple-demos/vertical-split.py new file mode 100755 index 0000000..b48d106 --- /dev/null +++ b/examples/full-screen/simple-demos/vertical-split.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python +""" +Vertical split example. +""" +from prompt_toolkit.application import Application +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout.containers import VSplit, Window +from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.layout.layout import Layout + +# 1. The layout +left_text = "\nVertical-split example. Press 'q' to quit.\n\n(left pane.)" +right_text = "\n(right pane.)" + + +body = VSplit( + [ + Window(FormattedTextControl(left_text)), + Window(width=1, char="|"), # Vertical line in the middle. + Window(FormattedTextControl(right_text)), + ] +) + + +# 2. Key bindings +kb = KeyBindings() + + +@kb.add("q") +def _(event): + "Quit application." + event.app.exit() + + +# 3. The `Application` +application = Application(layout=Layout(body), key_bindings=kb, full_screen=True) + + +def run(): + application.run() + + +if __name__ == "__main__": + run() diff --git a/examples/full-screen/split-screen.py b/examples/full-screen/split-screen.py new file mode 100755 index 0000000..af5403e --- /dev/null +++ b/examples/full-screen/split-screen.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python +""" +Simple example of a full screen application with a vertical split. + +This will show a window on the left for user input. When the user types, the +reversed input is shown on the right. Pressing Ctrl-Q will quit the application. +""" +from prompt_toolkit.application import Application +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout.containers import HSplit, VSplit, Window, WindowAlign +from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl +from prompt_toolkit.layout.layout import Layout + +# 3. Create the buffers +# ------------------ + +left_buffer = Buffer() +right_buffer = Buffer() + +# 1. First we create the layout +# -------------------------- + +left_window = Window(BufferControl(buffer=left_buffer)) +right_window = Window(BufferControl(buffer=right_buffer)) + + +body = VSplit( + [ + left_window, + # A vertical line in the middle. We explicitly specify the width, to make + # sure that the layout engine will not try to divide the whole width by + # three for all these windows. + Window(width=1, char="|", style="class:line"), + # Display the Result buffer on the right. + right_window, + ] +) + +# As a demonstration. Let's add a title bar to the top, displaying "Hello world". + +# somewhere, because usually the default key bindings include searching. (Press +# Ctrl-R.) It would be really annoying if the search key bindings are handled, +# but the user doesn't see any feedback. We will add the search toolbar to the +# bottom by using an HSplit. + + +def get_titlebar_text(): + return [ + ("class:title", " Hello world "), + ("class:title", " (Press [Ctrl-Q] to quit.)"), + ] + + +root_container = HSplit( + [ + # The titlebar. + Window( + height=1, + content=FormattedTextControl(get_titlebar_text), + align=WindowAlign.CENTER, + ), + # Horizontal separator. + Window(height=1, char="-", style="class:line"), + # The 'body', like defined above. + body, + ] +) + + +# 2. Adding key bindings +# -------------------- + +# As a demonstration, we will add just a ControlQ key binding to exit the +# application. Key bindings are registered in a +# `prompt_toolkit.key_bindings.registry.Registry` instance. We use the +# `load_default_key_bindings` utility function to create a registry that +# already contains the default key bindings. + +kb = KeyBindings() + +# Now add the Ctrl-Q binding. We have to pass `eager=True` here. The reason is +# that there is another key *sequence* that starts with Ctrl-Q as well. Yes, a +# key binding is linked to a sequence of keys, not necessarily one key. So, +# what happens if there is a key binding for the letter 'a' and a key binding +# for 'ab'. When 'a' has been pressed, nothing will happen yet. Because the +# next key could be a 'b', but it could as well be anything else. If it's a 'c' +# for instance, we'll handle the key binding for 'a' and then look for a key +# binding for 'c'. So, when there's a common prefix in a key binding sequence, +# prompt-toolkit will wait calling a handler, until we have enough information. + +# Now, There is an Emacs key binding for the [Ctrl-Q Any] sequence by default. +# Pressing Ctrl-Q followed by any other key will do a quoted insert. So to be +# sure that we won't wait for that key binding to match, but instead execute +# Ctrl-Q immediately, we can pass eager=True. (Don't make a habit of adding +# `eager=True` to all key bindings, but do it when it conflicts with another +# existing key binding, and you definitely want to override that behavior. + + +@kb.add("c-c", eager=True) +@kb.add("c-q", eager=True) +def _(event): + """ + Pressing Ctrl-Q or Ctrl-C will exit the user interface. + + Setting a return value means: quit the event loop that drives the user + interface and return this value from the `Application.run()` call. + + Note that Ctrl-Q does not work on all terminals. Sometimes it requires + executing `stty -ixon`. + """ + event.app.exit() + + +# Now we add an event handler that captures change events to the buffer on the +# left. If the text changes over there, we'll update the buffer on the right. + + +def default_buffer_changed(_): + """ + When the buffer on the left changes, update the buffer on + the right. We just reverse the text. + """ + right_buffer.text = left_buffer.text[::-1] + + +left_buffer.on_text_changed += default_buffer_changed + + +# 3. Creating an `Application` instance +# ---------------------------------- + +# This glues everything together. + +application = Application( + layout=Layout(root_container, focused_element=left_window), + key_bindings=kb, + # Let's add mouse support! + mouse_support=True, + # Using an alternate screen buffer means as much as: "run full screen". + # It switches the terminal to an alternate screen. + full_screen=True, +) + + +# 4. Run the application +# ------------------- + + +def run(): + # Run the interface. (This runs the event loop until Ctrl-Q is pressed.) + application.run() + + +if __name__ == "__main__": + run() diff --git a/examples/full-screen/text-editor.py b/examples/full-screen/text-editor.py new file mode 100755 index 0000000..9c0a414 --- /dev/null +++ b/examples/full-screen/text-editor.py @@ -0,0 +1,383 @@ +#!/usr/bin/env python +""" +A simple example of a Notepad-like text editor. +""" +import datetime +from asyncio import Future, ensure_future + +from prompt_toolkit.application import Application +from prompt_toolkit.application.current import get_app +from prompt_toolkit.completion import PathCompleter +from prompt_toolkit.filters import Condition +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout.containers import ( + ConditionalContainer, + Float, + HSplit, + VSplit, + Window, + WindowAlign, +) +from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.layout.dimension import D +from prompt_toolkit.layout.layout import Layout +from prompt_toolkit.layout.menus import CompletionsMenu +from prompt_toolkit.lexers import DynamicLexer, PygmentsLexer +from prompt_toolkit.search import start_search +from prompt_toolkit.styles import Style +from prompt_toolkit.widgets import ( + Button, + Dialog, + Label, + MenuContainer, + MenuItem, + SearchToolbar, + TextArea, +) + + +class ApplicationState: + """ + Application state. + + For the simplicity, we store this as a global, but better would be to + instantiate this as an object and pass at around. + """ + + show_status_bar = True + current_path = None + + +def get_statusbar_text(): + return " Press Ctrl-C to open menu. " + + +def get_statusbar_right_text(): + return " {}:{} ".format( + text_field.document.cursor_position_row + 1, + text_field.document.cursor_position_col + 1, + ) + + +search_toolbar = SearchToolbar() +text_field = TextArea( + lexer=DynamicLexer( + lambda: PygmentsLexer.from_filename( + ApplicationState.current_path or ".txt", sync_from_start=False + ) + ), + scrollbar=True, + line_numbers=True, + search_field=search_toolbar, +) + + +class TextInputDialog: + def __init__(self, title="", label_text="", completer=None): + self.future = Future() + + def accept_text(buf): + get_app().layout.focus(ok_button) + buf.complete_state = None + return True + + def accept(): + self.future.set_result(self.text_area.text) + + def cancel(): + self.future.set_result(None) + + self.text_area = TextArea( + completer=completer, + multiline=False, + width=D(preferred=40), + accept_handler=accept_text, + ) + + ok_button = Button(text="OK", handler=accept) + cancel_button = Button(text="Cancel", handler=cancel) + + self.dialog = Dialog( + title=title, + body=HSplit([Label(text=label_text), self.text_area]), + buttons=[ok_button, cancel_button], + width=D(preferred=80), + modal=True, + ) + + def __pt_container__(self): + return self.dialog + + +class MessageDialog: + def __init__(self, title, text): + self.future = Future() + + def set_done(): + self.future.set_result(None) + + ok_button = Button(text="OK", handler=(lambda: set_done())) + + self.dialog = Dialog( + title=title, + body=HSplit([Label(text=text)]), + buttons=[ok_button], + width=D(preferred=80), + modal=True, + ) + + def __pt_container__(self): + return self.dialog + + +body = HSplit( + [ + text_field, + search_toolbar, + ConditionalContainer( + content=VSplit( + [ + Window( + FormattedTextControl(get_statusbar_text), style="class:status" + ), + Window( + FormattedTextControl(get_statusbar_right_text), + style="class:status.right", + width=9, + align=WindowAlign.RIGHT, + ), + ], + height=1, + ), + filter=Condition(lambda: ApplicationState.show_status_bar), + ), + ] +) + + +# Global key bindings. +bindings = KeyBindings() + + +@bindings.add("c-c") +def _(event): + "Focus menu." + event.app.layout.focus(root_container.window) + + +# +# Handlers for menu items. +# + + +def do_open_file(): + async def coroutine(): + open_dialog = TextInputDialog( + title="Open file", + label_text="Enter the path of a file:", + completer=PathCompleter(), + ) + + path = await show_dialog_as_float(open_dialog) + ApplicationState.current_path = path + + if path is not None: + try: + with open(path, "rb") as f: + text_field.text = f.read().decode("utf-8", errors="ignore") + except OSError as e: + show_message("Error", f"{e}") + + ensure_future(coroutine()) + + +def do_about(): + show_message("About", "Text editor demo.\nCreated by Jonathan Slenders.") + + +def show_message(title, text): + async def coroutine(): + dialog = MessageDialog(title, text) + await show_dialog_as_float(dialog) + + ensure_future(coroutine()) + + +async def show_dialog_as_float(dialog): + "Coroutine." + float_ = Float(content=dialog) + root_container.floats.insert(0, float_) + + app = get_app() + + focused_before = app.layout.current_window + app.layout.focus(dialog) + result = await dialog.future + app.layout.focus(focused_before) + + if float_ in root_container.floats: + root_container.floats.remove(float_) + + return result + + +def do_new_file(): + text_field.text = "" + + +def do_exit(): + get_app().exit() + + +def do_time_date(): + text = datetime.datetime.now().isoformat() + text_field.buffer.insert_text(text) + + +def do_go_to(): + async def coroutine(): + dialog = TextInputDialog(title="Go to line", label_text="Line number:") + + line_number = await show_dialog_as_float(dialog) + + try: + line_number = int(line_number) + except ValueError: + show_message("Invalid line number") + else: + text_field.buffer.cursor_position = ( + text_field.buffer.document.translate_row_col_to_index( + line_number - 1, 0 + ) + ) + + ensure_future(coroutine()) + + +def do_undo(): + text_field.buffer.undo() + + +def do_cut(): + data = text_field.buffer.cut_selection() + get_app().clipboard.set_data(data) + + +def do_copy(): + data = text_field.buffer.copy_selection() + get_app().clipboard.set_data(data) + + +def do_delete(): + text_field.buffer.cut_selection() + + +def do_find(): + start_search(text_field.control) + + +def do_find_next(): + search_state = get_app().current_search_state + + cursor_position = text_field.buffer.get_search_position( + search_state, include_current_position=False + ) + text_field.buffer.cursor_position = cursor_position + + +def do_paste(): + text_field.buffer.paste_clipboard_data(get_app().clipboard.get_data()) + + +def do_select_all(): + text_field.buffer.cursor_position = 0 + text_field.buffer.start_selection() + text_field.buffer.cursor_position = len(text_field.buffer.text) + + +def do_status_bar(): + ApplicationState.show_status_bar = not ApplicationState.show_status_bar + + +# +# The menu container. +# + + +root_container = MenuContainer( + body=body, + menu_items=[ + MenuItem( + "File", + children=[ + MenuItem("New...", handler=do_new_file), + MenuItem("Open...", handler=do_open_file), + MenuItem("Save"), + MenuItem("Save as..."), + MenuItem("-", disabled=True), + MenuItem("Exit", handler=do_exit), + ], + ), + MenuItem( + "Edit", + children=[ + MenuItem("Undo", handler=do_undo), + MenuItem("Cut", handler=do_cut), + MenuItem("Copy", handler=do_copy), + MenuItem("Paste", handler=do_paste), + MenuItem("Delete", handler=do_delete), + MenuItem("-", disabled=True), + MenuItem("Find", handler=do_find), + MenuItem("Find next", handler=do_find_next), + MenuItem("Replace"), + MenuItem("Go To", handler=do_go_to), + MenuItem("Select All", handler=do_select_all), + MenuItem("Time/Date", handler=do_time_date), + ], + ), + MenuItem( + "View", + children=[MenuItem("Status Bar", handler=do_status_bar)], + ), + MenuItem( + "Info", + children=[MenuItem("About", handler=do_about)], + ), + ], + floats=[ + Float( + xcursor=True, + ycursor=True, + content=CompletionsMenu(max_height=16, scroll_offset=1), + ), + ], + key_bindings=bindings, +) + + +style = Style.from_dict( + { + "status": "reverse", + "shadow": "bg:#440044", + } +) + + +layout = Layout(root_container, focused_element=text_field) + + +application = Application( + layout=layout, + enable_page_navigation_bindings=True, + style=style, + mouse_support=True, + full_screen=True, +) + + +def run(): + application.run() + + +if __name__ == "__main__": + run() diff --git a/examples/gevent-get-input.py b/examples/gevent-get-input.py new file mode 100755 index 0000000..ecb89b4 --- /dev/null +++ b/examples/gevent-get-input.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python +""" +For testing: test to make sure that everything still works when gevent monkey +patches are applied. +""" +from gevent.monkey import patch_all + +from prompt_toolkit.eventloop.defaults import create_event_loop +from prompt_toolkit.shortcuts import PromptSession + +if __name__ == "__main__": + # Apply patches. + patch_all() + + # There were some issues in the past when the event loop had an input hook. + def dummy_inputhook(*a): + pass + + eventloop = create_event_loop(inputhook=dummy_inputhook) + + # Ask for input. + session = PromptSession("Give me some input: ", loop=eventloop) + answer = session.prompt() + print("You said: %s" % answer) diff --git a/examples/print-text/ansi-colors.py b/examples/print-text/ansi-colors.py new file mode 100755 index 0000000..7bd3831 --- /dev/null +++ b/examples/print-text/ansi-colors.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python +""" +Demonstration of all the ANSI colors. +""" +from prompt_toolkit import print_formatted_text +from prompt_toolkit.formatted_text import HTML, FormattedText + +print = print_formatted_text + + +def main(): + wide_space = ("", " ") + space = ("", " ") + + print(HTML("\n<u>Foreground colors</u>")) + print( + FormattedText( + [ + ("ansiblack", "ansiblack"), + wide_space, + ("ansired", "ansired"), + wide_space, + ("ansigreen", "ansigreen"), + wide_space, + ("ansiyellow", "ansiyellow"), + wide_space, + ("ansiblue", "ansiblue"), + wide_space, + ("ansimagenta", "ansimagenta"), + wide_space, + ("ansicyan", "ansicyan"), + wide_space, + ("ansigray", "ansigray"), + wide_space, + ("", "\n"), + ("ansibrightblack", "ansibrightblack"), + space, + ("ansibrightred", "ansibrightred"), + space, + ("ansibrightgreen", "ansibrightgreen"), + space, + ("ansibrightyellow", "ansibrightyellow"), + space, + ("ansibrightblue", "ansibrightblue"), + space, + ("ansibrightmagenta", "ansibrightmagenta"), + space, + ("ansibrightcyan", "ansibrightcyan"), + space, + ("ansiwhite", "ansiwhite"), + space, + ] + ) + ) + + print(HTML("\n<u>Background colors</u>")) + print( + FormattedText( + [ + ("bg:ansiblack ansiwhite", "ansiblack"), + wide_space, + ("bg:ansired", "ansired"), + wide_space, + ("bg:ansigreen", "ansigreen"), + wide_space, + ("bg:ansiyellow", "ansiyellow"), + wide_space, + ("bg:ansiblue ansiwhite", "ansiblue"), + wide_space, + ("bg:ansimagenta", "ansimagenta"), + wide_space, + ("bg:ansicyan", "ansicyan"), + wide_space, + ("bg:ansigray", "ansigray"), + wide_space, + ("", "\n"), + ("bg:ansibrightblack", "ansibrightblack"), + space, + ("bg:ansibrightred", "ansibrightred"), + space, + ("bg:ansibrightgreen", "ansibrightgreen"), + space, + ("bg:ansibrightyellow", "ansibrightyellow"), + space, + ("bg:ansibrightblue", "ansibrightblue"), + space, + ("bg:ansibrightmagenta", "ansibrightmagenta"), + space, + ("bg:ansibrightcyan", "ansibrightcyan"), + space, + ("bg:ansiwhite", "ansiwhite"), + space, + ] + ) + ) + print() + + +if __name__ == "__main__": + main() diff --git a/examples/print-text/ansi.py b/examples/print-text/ansi.py new file mode 100755 index 0000000..618775c --- /dev/null +++ b/examples/print-text/ansi.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python +""" +Demonstration of how to print using ANSI escape sequences. + +The advantage here is that this is cross platform. The escape sequences will be +parsed and turned into appropriate Win32 API calls on Windows. +""" +from prompt_toolkit import print_formatted_text +from prompt_toolkit.formatted_text import ANSI, HTML + +print = print_formatted_text + + +def title(text): + print(HTML("\n<u><b>{}</b></u>").format(text)) + + +def main(): + title("Special formatting") + print(ANSI(" \x1b[1mBold")) + print(ANSI(" \x1b[6mBlink")) + print(ANSI(" \x1b[3mItalic")) + print(ANSI(" \x1b[7mReverse")) + print(ANSI(" \x1b[4mUnderline")) + print(ANSI(" \x1b[9mStrike")) + print(ANSI(" \x1b[8mHidden\x1b[0m (Hidden)")) + + # Ansi colors. + title("ANSI colors") + + print(ANSI(" \x1b[91mANSI Red")) + print(ANSI(" \x1b[94mANSI Blue")) + + # Other named colors. + title("Named colors") + + print(ANSI(" \x1b[38;5;214morange")) + print(ANSI(" \x1b[38;5;90mpurple")) + + # Background colors. + title("Background colors") + + print(ANSI(" \x1b[97;101mANSI Red")) + print(ANSI(" \x1b[97;104mANSI Blue")) + + print() + + +if __name__ == "__main__": + main() diff --git a/examples/print-text/html.py b/examples/print-text/html.py new file mode 100755 index 0000000..5276fe3 --- /dev/null +++ b/examples/print-text/html.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python +""" +Demonstration of how to print using the HTML class. +""" +from prompt_toolkit import HTML, print_formatted_text + +print = print_formatted_text + + +def title(text): + print(HTML("\n<u><b>{}</b></u>").format(text)) + + +def main(): + title("Special formatting") + print(HTML(" <b>Bold</b>")) + print(HTML(" <blink>Blink</blink>")) + print(HTML(" <i>Italic</i>")) + print(HTML(" <reverse>Reverse</reverse>")) + print(HTML(" <u>Underline</u>")) + print(HTML(" <s>Strike</s>")) + print(HTML(" <hidden>Hidden</hidden> (hidden)")) + + # Ansi colors. + title("ANSI colors") + + print(HTML(" <ansired>ANSI Red</ansired>")) + print(HTML(" <ansiblue>ANSI Blue</ansiblue>")) + + # Other named colors. + title("Named colors") + + print(HTML(" <orange>orange</orange>")) + print(HTML(" <purple>purple</purple>")) + + # Background colors. + title("Background colors") + + print(HTML(' <style fg="ansiwhite" bg="ansired">ANSI Red</style>')) + print(HTML(' <style fg="ansiwhite" bg="ansiblue">ANSI Blue</style>')) + + # Interpolation. + title("HTML interpolation (see source)") + + print(HTML(" <i>{}</i>").format("<test>")) + print(HTML(" <b>{text}</b>").format(text="<test>")) + print(HTML(" <u>%s</u>") % ("<text>",)) + + print() + + +if __name__ == "__main__": + main() diff --git a/examples/print-text/named-colors.py b/examples/print-text/named-colors.py new file mode 100755 index 0000000..ea3f0ba --- /dev/null +++ b/examples/print-text/named-colors.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python +""" +Demonstration of all the ANSI colors. +""" +from prompt_toolkit import HTML, print_formatted_text +from prompt_toolkit.formatted_text import FormattedText +from prompt_toolkit.output import ColorDepth +from prompt_toolkit.styles.named_colors import NAMED_COLORS + +print = print_formatted_text + + +def main(): + tokens = FormattedText([("fg:" + name, name + " ") for name in NAMED_COLORS]) + + print(HTML("\n<u>Named colors, using 16 color output.</u>")) + print("(Note that it doesn't really make sense to use named colors ") + print("with only 16 color output.)") + print(tokens, color_depth=ColorDepth.DEPTH_4_BIT) + + print(HTML("\n<u>Named colors, use 256 colors.</u>")) + print(tokens) + + print(HTML("\n<u>Named colors, using True color output.</u>")) + print(tokens, color_depth=ColorDepth.TRUE_COLOR) + + +if __name__ == "__main__": + main() diff --git a/examples/print-text/print-formatted-text.py b/examples/print-text/print-formatted-text.py new file mode 100755 index 0000000..4b09ae2 --- /dev/null +++ b/examples/print-text/print-formatted-text.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python +""" +Example of printing colored text to the output. +""" +from prompt_toolkit import print_formatted_text +from prompt_toolkit.formatted_text import ANSI, HTML, FormattedText +from prompt_toolkit.styles import Style + +print = print_formatted_text + + +def main(): + style = Style.from_dict( + { + "hello": "#ff0066", + "world": "#44ff44 italic", + } + ) + + # Print using a a list of text fragments. + text_fragments = FormattedText( + [ + ("class:hello", "Hello "), + ("class:world", "World"), + ("", "\n"), + ] + ) + print(text_fragments, style=style) + + # Print using an HTML object. + print(HTML("<hello>hello</hello> <world>world</world>\n"), style=style) + + # Print using an HTML object with inline styling. + print( + HTML( + '<style fg="#ff0066">hello</style> ' + '<style fg="#44ff44"><i>world</i></style>\n' + ) + ) + + # Print using ANSI escape sequences. + print(ANSI("\x1b[31mhello \x1b[32mworld\n")) + + +if __name__ == "__main__": + main() diff --git a/examples/print-text/print-frame.py b/examples/print-text/print-frame.py new file mode 100755 index 0000000..fb703c5 --- /dev/null +++ b/examples/print-text/print-frame.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python +""" +Example usage of 'print_container', a tool to print +any layout in a non-interactive way. +""" +from prompt_toolkit.shortcuts import print_container +from prompt_toolkit.widgets import Frame, TextArea + +print_container( + Frame( + TextArea(text="Hello world!\n"), + title="Stage: parse", + ) +) diff --git a/examples/print-text/prompt-toolkit-logo-ansi-art.py b/examples/print-text/prompt-toolkit-logo-ansi-art.py new file mode 100755 index 0000000..617a506 --- /dev/null +++ b/examples/print-text/prompt-toolkit-logo-ansi-art.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python +r""" +This prints the prompt_toolkit logo at the terminal. +The ANSI output was generated using "pngtoansi": https://github.com/crgimenes/pngtoansi +(ESC still had to be replaced with \x1b +""" +from prompt_toolkit import print_formatted_text +from prompt_toolkit.formatted_text import ANSI + +print_formatted_text( + ANSI( + """ +\x1b[48;2;0;0;0m \x1b[m +\x1b[48;2;0;0;0m \x1b[48;2;0;249;0m\x1b[38;2;0;0;0m▀\x1b[48;2;0;209;0m▀\x1b[48;2;0;207;0m\x1b[38;2;6;34;6m▀\x1b[48;2;0;66;0m\x1b[38;2;30;171;30m▀\x1b[48;2;0;169;0m\x1b[38;2;51;35;51m▀\x1b[48;2;0;248;0m\x1b[38;2;49;194;49m▀\x1b[48;2;0;111;0m\x1b[38;2;25;57;25m▀\x1b[48;2;140;195;140m\x1b[38;2;3;17;3m▀\x1b[48;2;30;171;30m\x1b[38;2;0;0;0m▀\x1b[48;2;0;0;0m \x1b[m +\x1b[48;2;0;0;0m \x1b[48;2;77;127;78m\x1b[38;2;118;227;108m▀\x1b[48;2;216;1;13m\x1b[38;2;49;221;57m▀\x1b[48;2;26;142;76m\x1b[38;2;108;146;165m▀\x1b[48;2;26;142;90m\x1b[38;2;209;197;114m▀▀\x1b[38;2;209;146;114m▀\x1b[48;2;26;128;90m\x1b[38;2;158;197;114m▀\x1b[48;2;58;210;70m\x1b[38;2;223;152;89m▀\x1b[48;2;232;139;44m\x1b[38;2;97;121;146m▀\x1b[48;2;233;139;45m\x1b[38;2;140;188;183m▀\x1b[48;2;231;139;44m\x1b[38;2;40;168;8m▀\x1b[48;2;228;140;44m\x1b[38;2;37;169;7m▀\x1b[48;2;227;140;44m\x1b[38;2;36;169;7m▀\x1b[48;2;211;142;41m\x1b[38;2;23;171;5m▀\x1b[48;2;86;161;17m\x1b[38;2;2;174;1m▀\x1b[48;2;0;175;0m \x1b[48;2;0;254;0m\x1b[38;2;190;119;190m▀\x1b[48;2;92;39;23m\x1b[38;2;125;50;114m▀\x1b[48;2;43;246;41m\x1b[38;2;49;10;165m▀\x1b[48;2;12;128;90m\x1b[38;2;209;197;114m▀\x1b[48;2;26;128;90m▀▀▀▀\x1b[48;2;26;128;76m▀\x1b[48;2;26;128;90m\x1b[38;2;209;247;114m▀▀\x1b[38;2;209;197;114m▀\x1b[48;2;26;128;76m\x1b[38;2;209;247;114m▀\x1b[48;2;26;128;90m▀▀▀\x1b[48;2;26;128;76m▀\x1b[48;2;26;128;90m▀▀\x1b[48;2;12;128;76m▀\x1b[48;2;12;113;90m\x1b[38;2;209;247;64m▀\x1b[38;2;209;247;114m▀\x1b[48;2;12;128;90m▀\x1b[48;2;12;113;90m▀\x1b[48;2;12;113;76m\x1b[38;2;209;247;64m▀\x1b[48;2;12;128;90m▀\x1b[48;2;12;113;90m▀\x1b[48;2;12;113;76m\x1b[38;2;209;247;114m▀\x1b[48;2;12;113;90m\x1b[38;2;209;247;64m▀\x1b[48;2;26;128;90m\x1b[38;2;151;129;163m▀\x1b[48;2;115;120;103m\x1b[38;2;62;83;227m▀\x1b[48;2;138;14;25m\x1b[38;2;104;106;160m▀\x1b[48;2;0;0;57m\x1b[38;2;0;0;0m▀\x1b[m +\x1b[48;2;249;147;8m\x1b[38;2;172;69;38m▀\x1b[48;2;197;202;10m\x1b[38;2;82;192;58m▀\x1b[48;2;248;124;45m\x1b[38;2;251;131;47m▀\x1b[48;2;248;124;44m▀\x1b[48;2;248;124;45m▀▀\x1b[48;2;248;124;44m▀\x1b[48;2;248;124;45m▀\x1b[48;2;248;125;45m\x1b[38;2;251;130;47m▀\x1b[48;2;248;124;45m\x1b[38;2;252;130;47m▀\x1b[48;2;248;125;45m\x1b[38;2;252;131;47m▀\x1b[38;2;252;130;47m▀\x1b[38;2;252;131;47m▀▀\x1b[48;2;249;125;45m\x1b[38;2;255;130;48m▀\x1b[48;2;233;127;42m\x1b[38;2;190;141;35m▀\x1b[48;2;57;163;10m\x1b[38;2;13;172;3m▀\x1b[48;2;0;176;0m\x1b[38;2;0;175;0m▀\x1b[48;2;7;174;1m\x1b[38;2;35;169;7m▀\x1b[48;2;178;139;32m\x1b[38;2;220;136;41m▀\x1b[48;2;252;124;45m\x1b[38;2;253;131;47m▀\x1b[48;2;248;125;45m\x1b[38;2;251;131;47m▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\x1b[48;2;248;125;44m▀\x1b[48;2;248;135;61m\x1b[38;2;251;132;48m▀\x1b[48;2;250;173;122m\x1b[38;2;251;133;50m▀\x1b[48;2;249;155;93m\x1b[38;2;251;132;49m▀\x1b[48;2;248;132;55m\x1b[38;2;251;132;48m▀\x1b[48;2;250;173;122m\x1b[38;2;251;134;51m▀\x1b[48;2;250;163;106m\x1b[38;2;251;134;50m▀\x1b[48;2;248;128;49m\x1b[38;2;251;132;47m▀\x1b[48;2;250;166;110m\x1b[38;2;251;135;52m▀\x1b[48;2;250;175;125m\x1b[38;2;251;136;54m▀\x1b[48;2;248;132;56m\x1b[38;2;251;132;48m▀\x1b[48;2;248;220;160m\x1b[38;2;105;247;172m▀\x1b[48;2;62;101;236m\x1b[38;2;11;207;160m▀\x1b[m +\x1b[48;2;138;181;197m\x1b[38;2;205;36;219m▀\x1b[48;2;177;211;200m\x1b[38;2;83;231;105m▀\x1b[48;2;242;113;40m\x1b[38;2;245;119;42m▀\x1b[48;2;243;113;41m▀\x1b[48;2;245;114;41m▀▀▀▀▀▀▀▀\x1b[38;2;245;119;43m▀▀▀\x1b[48;2;247;114;41m\x1b[38;2;246;119;43m▀\x1b[48;2;202;125;34m\x1b[38;2;143;141;25m▀\x1b[48;2;84;154;14m\x1b[38;2;97;152;17m▀\x1b[48;2;36;166;6m▀\x1b[48;2;139;140;23m\x1b[38;2;183;133;32m▀\x1b[48;2;248;114;41m\x1b[38;2;248;118;43m▀\x1b[48;2;245;115;41m\x1b[38;2;245;119;43m▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\x1b[38;2;245;119;42m▀\x1b[48;2;246;117;44m\x1b[38;2;246;132;62m▀\x1b[48;2;246;123;54m\x1b[38;2;249;180;138m▀\x1b[48;2;246;120;49m\x1b[38;2;247;157;102m▀\x1b[48;2;246;116;42m\x1b[38;2;246;127;54m▀\x1b[48;2;246;121;50m\x1b[38;2;248;174;128m▀\x1b[48;2;246;120;48m\x1b[38;2;248;162;110m▀\x1b[48;2;246;116;41m\x1b[38;2;245;122;47m▀\x1b[48;2;246;118;46m\x1b[38;2;248;161;108m▀\x1b[48;2;244;118;47m\x1b[38;2;248;171;123m▀\x1b[48;2;243;115;42m\x1b[38;2;246;127;54m▀\x1b[48;2;179;52;29m\x1b[38;2;86;152;223m▀\x1b[48;2;141;225;95m\x1b[38;2;247;146;130m▀\x1b[m +\x1b[48;2;50;237;108m\x1b[38;2;94;70;153m▀\x1b[48;2;206;221;133m\x1b[38;2;64;240;39m▀\x1b[48;2;233;100;36m\x1b[38;2;240;107;38m▀\x1b[48;2;114;56;22m\x1b[38;2;230;104;37m▀\x1b[48;2;24;20;10m\x1b[38;2;193;90;33m▀\x1b[48;2;21;19;9m\x1b[38;2;186;87;32m▀▀▀▀▀▀▀\x1b[38;2;186;87;33m▀▀▀\x1b[48;2;22;18;10m\x1b[38;2;189;86;33m▀\x1b[48;2;18;36;8m\x1b[38;2;135;107;24m▀\x1b[48;2;3;153;2m\x1b[38;2;5;171;1m▀\x1b[48;2;0;177;0m \x1b[48;2;4;158;2m\x1b[38;2;69;147;12m▀\x1b[48;2;19;45;8m\x1b[38;2;185;89;32m▀\x1b[48;2;22;17;10m\x1b[38;2;186;87;33m▀\x1b[48;2;21;19;9m▀▀▀▀▀▀▀▀\x1b[48;2;21;19;10m▀▀\x1b[48;2;21;19;9m▀▀▀▀\x1b[48;2;21;19;10m▀▀▀\x1b[38;2;186;87;32m▀▀\x1b[48;2;21;19;9m\x1b[38;2;186;87;33m▀\x1b[48;2;21;19;10m\x1b[38;2;186;87;32m▀▀\x1b[48;2;21;19;9m\x1b[38;2;186;87;33m▀\x1b[48;2;22;19;10m\x1b[38;2;191;89;33m▀\x1b[48;2;95;49;20m\x1b[38;2;226;103;37m▀\x1b[48;2;227;99;36m\x1b[38;2;241;109;39m▀\x1b[48;2;80;140;154m\x1b[38;2;17;240;92m▀\x1b[48;2;221;58;175m\x1b[38;2;71;14;245m▀\x1b[m +\x1b[48;2;195;38;42m\x1b[38;2;5;126;86m▀\x1b[48;2;139;230;67m\x1b[38;2;253;201;228m▀\x1b[48;2;208;82;30m\x1b[38;2;213;89;32m▀\x1b[48;2;42;26;12m\x1b[38;2;44;27;12m▀\x1b[48;2;9;14;7m\x1b[38;2;8;13;7m▀\x1b[48;2;11;15;8m\x1b[38;2;10;14;7m▀▀▀▀▀▀▀▀▀▀▀\x1b[48;2;11;12;8m\x1b[38;2;10;17;7m▀\x1b[48;2;7;71;5m\x1b[38;2;4;120;3m▀\x1b[48;2;1;164;1m\x1b[38;2;0;178;0m▀\x1b[48;2;4;118;3m\x1b[38;2;0;177;0m▀\x1b[48;2;5;108;3m\x1b[38;2;4;116;3m▀\x1b[48;2;7;75;5m\x1b[38;2;10;23;7m▀\x1b[48;2;10;33;7m\x1b[38;2;10;12;7m▀\x1b[48;2;11;13;8m\x1b[38;2;10;14;7m▀\x1b[48;2;11;14;8m▀\x1b[48;2;11;15;8m▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\x1b[48;2;10;14;7m\x1b[38;2;9;14;7m▀\x1b[48;2;30;21;10m\x1b[38;2;30;22;10m▀\x1b[48;2;195;79;29m\x1b[38;2;200;84;31m▀\x1b[48;2;205;228;23m\x1b[38;2;111;40;217m▀\x1b[48;2;9;217;69m\x1b[38;2;115;137;104m▀\x1b[m +\x1b[48;2;106;72;209m\x1b[38;2;151;183;253m▀\x1b[48;2;120;239;0m\x1b[38;2;25;2;162m▀\x1b[48;2;203;72;26m\x1b[38;2;206;77;28m▀\x1b[48;2;42;24;11m\x1b[38;2;42;25;11m▀\x1b[48;2;9;14;7m \x1b[48;2;11;15;8m \x1b[38;2;11;14;8m▀\x1b[48;2;11;13;8m\x1b[38;2;10;28;7m▀\x1b[48;2;9;36;6m\x1b[38;2;7;78;5m▀\x1b[48;2;2;153;1m\x1b[38;2;6;94;4m▀\x1b[48;2;0;178;0m\x1b[38;2;2;156;1m▀\x1b[48;2;0;175;0m\x1b[38;2;1;167;1m▀\x1b[48;2;0;177;0m\x1b[38;2;2;145;2m▀\x1b[48;2;2;147;2m\x1b[38;2;8;54;6m▀\x1b[48;2;9;38;6m\x1b[38;2;11;13;8m▀\x1b[48;2;11;13;8m\x1b[38;2;11;14;8m▀\x1b[48;2;11;15;8m \x1b[48;2;10;14;7m \x1b[48;2;29;20;10m\x1b[38;2;29;21;10m▀\x1b[48;2;190;69;25m\x1b[38;2;193;74;27m▀\x1b[48;2;136;91;148m\x1b[38;2;42;159;86m▀\x1b[48;2;89;85;149m\x1b[38;2;160;5;219m▀\x1b[m +\x1b[48;2;229;106;143m\x1b[38;2;40;239;187m▀\x1b[48;2;196;134;237m\x1b[38;2;6;11;95m▀\x1b[48;2;197;60;22m\x1b[38;2;201;67;24m▀\x1b[48;2;41;22;10m\x1b[38;2;41;23;11m▀\x1b[48;2;9;14;7m \x1b[48;2;11;15;8m \x1b[48;2;10;14;7m\x1b[38;2;11;15;8m▀▀\x1b[48;2;11;15;8m \x1b[38;2;11;14;8m▀\x1b[48;2;11;14;8m\x1b[38;2;11;16;7m▀\x1b[48;2;11;15;7m\x1b[38;2;7;79;5m▀\x1b[48;2;7;68;5m\x1b[38;2;1;164;1m▀\x1b[48;2;2;153;1m\x1b[38;2;0;176;0m▀\x1b[48;2;2;154;1m\x1b[38;2;0;175;0m▀\x1b[48;2;5;107;3m\x1b[38;2;1;171;1m▀\x1b[48;2;4;115;3m\x1b[38;2;5;105;3m▀\x1b[48;2;6;84;4m\x1b[38;2;11;18;7m▀\x1b[48;2;10;30;7m\x1b[38;2;11;13;8m▀\x1b[48;2;11;13;8m\x1b[38;2;11;15;8m▀\x1b[48;2;11;14;8m▀\x1b[48;2;11;15;8m \x1b[48;2;10;14;7m \x1b[48;2;29;19;9m\x1b[38;2;29;20;10m▀\x1b[48;2;185;58;22m\x1b[38;2;188;64;24m▀\x1b[48;2;68;241;49m\x1b[38;2;199;22;211m▀\x1b[48;2;133;139;8m\x1b[38;2;239;129;78m▀\x1b[m +\x1b[48;2;74;30;32m\x1b[38;2;163;185;76m▀\x1b[48;2;110;172;9m\x1b[38;2;177;1;123m▀\x1b[48;2;189;43;16m\x1b[38;2;193;52;19m▀\x1b[48;2;39;20;9m\x1b[38;2;40;21;10m▀\x1b[48;2;9;14;7m \x1b[48;2;11;15;8m \x1b[48;2;11;14;7m\x1b[38;2;11;15;8m▀\x1b[48;2;9;14;7m\x1b[38;2;11;14;8m▀\x1b[48;2;106;54;38m\x1b[38;2;31;24;15m▀\x1b[48;2;164;71;49m\x1b[38;2;24;20;12m▀\x1b[48;2;94;46;31m\x1b[38;2;8;14;7m▀\x1b[48;2;36;24;15m\x1b[38;2;9;14;7m▀\x1b[48;2;11;15;8m\x1b[38;2;11;14;7m▀\x1b[48;2;8;14;7m\x1b[38;2;11;15;8m▀\x1b[48;2;10;14;7m▀\x1b[48;2;11;15;8m \x1b[38;2;11;14;8m▀\x1b[48;2;11;14;8m\x1b[38;2;11;13;8m▀\x1b[48;2;11;13;8m\x1b[38;2;9;45;6m▀\x1b[48;2;10;19;7m\x1b[38;2;7;75;5m▀\x1b[48;2;6;83;4m\x1b[38;2;2;143;2m▀\x1b[48;2;2;156;1m\x1b[38;2;0;176;0m▀\x1b[48;2;0;177;0m\x1b[38;2;0;175;0m▀\x1b[38;2;3;134;2m▀\x1b[48;2;2;152;1m\x1b[38;2;9;46;6m▀\x1b[48;2;8;60;5m\x1b[38;2;11;13;8m▀\x1b[48;2;11;14;7m\x1b[38;2;11;14;8m▀\x1b[48;2;11;14;8m\x1b[38;2;11;15;8m▀\x1b[48;2;11;15;8m \x1b[48;2;10;14;7m \x1b[48;2;28;18;9m \x1b[48;2;177;43;16m\x1b[38;2;181;51;19m▀\x1b[48;2;93;35;236m\x1b[38;2;224;10;142m▀\x1b[48;2;72;51;52m\x1b[38;2;213;112;158m▀\x1b[m +\x1b[48;2;175;209;155m\x1b[38;2;7;131;221m▀\x1b[48;2;24;0;85m\x1b[38;2;44;86;152m▀\x1b[48;2;181;27;10m\x1b[38;2;185;35;13m▀\x1b[48;2;38;17;8m\x1b[38;2;39;18;9m▀\x1b[48;2;9;14;7m \x1b[48;2;11;15;8m \x1b[48;2;11;14;7m \x1b[48;2;9;14;7m \x1b[48;2;87;43;32m\x1b[38;2;114;54;39m▀\x1b[48;2;188;71;54m\x1b[38;2;211;82;59m▀\x1b[48;2;203;73;55m\x1b[38;2;204;80;57m▀\x1b[48;2;205;73;55m\x1b[38;2;178;71;51m▀\x1b[48;2;204;74;55m\x1b[38;2;119;52;37m▀\x1b[48;2;188;69;52m\x1b[38;2;54;29;19m▀\x1b[48;2;141;55;41m\x1b[38;2;16;17;9m▀\x1b[48;2;75;35;24m\x1b[38;2;8;14;7m▀\x1b[48;2;26;20;12m\x1b[38;2;10;14;7m▀\x1b[48;2;9;14;7m\x1b[38;2;11;14;7m▀\x1b[38;2;11;15;8m▀\x1b[48;2;11;14;7m▀\x1b[48;2;11;15;8m \x1b[38;2;11;14;8m▀\x1b[48;2;11;14;8m \x1b[48;2;11;13;8m\x1b[38;2;9;45;6m▀\x1b[48;2;10;23;7m\x1b[38;2;4;123;3m▀\x1b[48;2;7;75;5m\x1b[38;2;1;172;1m▀\x1b[48;2;6;84;4m\x1b[38;2;2;154;1m▀\x1b[48;2;4;114;3m\x1b[38;2;5;107;3m▀\x1b[48;2;5;103;4m\x1b[38;2;10;29;7m▀\x1b[48;2;10;23;7m\x1b[38;2;11;13;8m▀\x1b[48;2;11;14;8m\x1b[38;2;11;15;8m▀\x1b[48;2;11;15;8m \x1b[48;2;10;14;7m \x1b[48;2;27;16;8m\x1b[38;2;27;17;9m▀\x1b[48;2;170;27;10m\x1b[38;2;174;35;13m▀\x1b[48;2;118;117;199m\x1b[38;2;249;61;74m▀\x1b[48;2;10;219;61m\x1b[38;2;187;245;202m▀\x1b[m +\x1b[48;2;20;155;44m\x1b[38;2;86;54;110m▀\x1b[48;2;195;85;113m\x1b[38;2;214;171;227m▀\x1b[48;2;173;10;4m\x1b[38;2;177;19;7m▀\x1b[48;2;37;14;7m\x1b[38;2;37;16;8m▀\x1b[48;2;9;15;8m\x1b[38;2;9;14;7m▀\x1b[48;2;11;15;8m \x1b[38;2;11;14;7m▀\x1b[48;2;11;14;7m\x1b[38;2;15;17;9m▀\x1b[48;2;9;14;7m\x1b[38;2;50;29;20m▀\x1b[48;2;10;15;8m\x1b[38;2;112;47;36m▀\x1b[48;2;33;22;15m\x1b[38;2;170;61;48m▀\x1b[48;2;88;38;29m\x1b[38;2;197;66;53m▀\x1b[48;2;151;53;43m\x1b[38;2;201;67;53m▀\x1b[48;2;189;60;50m▀\x1b[48;2;198;60;51m\x1b[38;2;194;65;52m▀\x1b[38;2;160;56;44m▀\x1b[48;2;196;60;50m\x1b[38;2;99;40;30m▀\x1b[48;2;174;55;47m\x1b[38;2;41;24;16m▀\x1b[48;2;122;43;35m\x1b[38;2;12;15;8m▀\x1b[48;2;59;27;20m\x1b[38;2;8;14;7m▀\x1b[48;2;16;16;9m\x1b[38;2;10;14;7m▀\x1b[48;2;10;14;7m\x1b[38;2;11;15;8m▀\x1b[48;2;11;15;8m \x1b[38;2;11;14;8m▀\x1b[48;2;11;14;8m\x1b[38;2;11;12;8m▀\x1b[48;2;10;25;7m\x1b[38;2;7;79;5m▀\x1b[48;2;3;141;2m\x1b[38;2;1;174;1m▀\x1b[48;2;0;178;0m\x1b[38;2;1;169;1m▀\x1b[48;2;6;88;4m\x1b[38;2;8;56;6m▀\x1b[48;2;11;12;8m \x1b[48;2;11;14;8m\x1b[38;2;11;15;8m▀\x1b[48;2;11;15;8m \x1b[48;2;10;14;7m \x1b[48;2;26;15;8m\x1b[38;2;27;15;8m▀\x1b[48;2;162;12;5m\x1b[38;2;166;20;8m▀\x1b[48;2;143;168;130m\x1b[38;2;18;142;37m▀\x1b[48;2;240;96;105m\x1b[38;2;125;158;211m▀\x1b[m +\x1b[48;2;54;0;0m\x1b[38;2;187;22;0m▀\x1b[48;2;204;0;0m\x1b[38;2;128;208;0m▀\x1b[48;2;162;1;1m\x1b[38;2;168;3;1m▀\x1b[48;2;35;13;7m\x1b[38;2;36;13;7m▀\x1b[48;2;9;15;8m \x1b[48;2;11;15;8m \x1b[38;2;11;14;7m▀\x1b[38;2;9;14;7m▀\x1b[38;2;8;14;7m▀\x1b[48;2;10;14;7m\x1b[38;2;21;18;11m▀\x1b[48;2;7;13;6m\x1b[38;2;65;30;23m▀\x1b[48;2;12;16;9m\x1b[38;2;129;45;38m▀\x1b[48;2;57;29;23m\x1b[38;2;176;53;47m▀\x1b[48;2;148;49;44m\x1b[38;2;191;53;48m▀\x1b[48;2;187;52;48m\x1b[38;2;192;53;48m▀\x1b[48;2;186;51;47m\x1b[38;2;194;54;49m▀\x1b[48;2;182;52;47m\x1b[38;2;178;52;46m▀\x1b[48;2;59;27;21m\x1b[38;2;53;26;19m▀\x1b[48;2;8;14;7m \x1b[48;2;11;15;8m \x1b[48;2;11;14;8m\x1b[38;2;11;15;8m▀\x1b[48;2;11;12;8m\x1b[38;2;11;14;8m▀\x1b[48;2;10;30;7m\x1b[38;2;10;23;7m▀\x1b[48;2;5;110;3m\x1b[38;2;3;138;2m▀\x1b[48;2;2;149;2m\x1b[38;2;0;181;0m▀\x1b[48;2;6;92;4m\x1b[38;2;5;100;4m▀\x1b[48;2;11;13;8m \x1b[48;2;11;14;8m \x1b[48;2;11;15;8m \x1b[48;2;10;15;8m \x1b[48;2;25;14;7m\x1b[38;2;26;14;7m▀\x1b[48;2;152;2;1m\x1b[38;2;158;5;2m▀\x1b[48;2;6;0;0m\x1b[38;2;44;193;0m▀\x1b[48;2;108;0;0m\x1b[38;2;64;70;0m▀\x1b[m +\x1b[48;2;44;0;0m\x1b[38;2;177;0;0m▀\x1b[48;2;147;0;0m\x1b[38;2;71;0;0m▀\x1b[48;2;148;1;1m\x1b[38;2;155;1;1m▀\x1b[48;2;33;13;7m\x1b[38;2;34;13;7m▀\x1b[48;2;9;15;8m \x1b[48;2;11;15;8m \x1b[48;2;11;14;7m\x1b[38;2;11;15;8m▀\x1b[48;2;10;14;7m▀\x1b[48;2;9;14;7m▀\x1b[48;2;13;16;9m\x1b[38;2;11;14;7m▀\x1b[48;2;42;24;17m\x1b[38;2;9;14;7m▀\x1b[48;2;97;38;32m\x1b[38;2;10;15;8m▀\x1b[48;2;149;49;44m\x1b[38;2;30;21;14m▀\x1b[48;2;174;52;48m\x1b[38;2;79;34;28m▀\x1b[48;2;178;52;48m\x1b[38;2;136;45;40m▀\x1b[38;2;172;51;47m▀\x1b[48;2;173;52;48m\x1b[38;2;181;52;48m▀\x1b[48;2;147;47;42m\x1b[38;2;183;52;48m▀\x1b[48;2;94;35;30m\x1b[38;2;177;52;48m▀\x1b[48;2;25;19;12m\x1b[38;2;56;27;20m▀\x1b[48;2;10;14;7m\x1b[38;2;8;14;7m▀\x1b[48;2;11;12;8m\x1b[38;2;11;15;8m▀\x1b[48;2;10;23;7m\x1b[38;2;11;14;8m▀\x1b[48;2;7;76;5m\x1b[38;2;11;13;8m▀\x1b[48;2;2;152;1m\x1b[38;2;9;45;6m▀\x1b[48;2;0;177;0m\x1b[38;2;5;106;3m▀\x1b[48;2;0;178;0m\x1b[38;2;4;123;3m▀\x1b[48;2;1;168;1m\x1b[38;2;5;104;3m▀\x1b[48;2;8;53;6m\x1b[38;2;9;47;6m▀\x1b[48;2;11;12;8m\x1b[38;2;11;13;8m▀\x1b[48;2;11;15;8m \x1b[48;2;10;15;8m \x1b[48;2;24;14;7m\x1b[38;2;25;14;7m▀\x1b[48;2;140;2;1m\x1b[38;2;146;2;1m▀\x1b[48;2;219;0;0m\x1b[38;2;225;0;0m▀\x1b[48;2;126;0;0m\x1b[38;2;117;0;0m▀\x1b[m +\x1b[48;2;34;0;0m\x1b[38;2;167;0;0m▀\x1b[48;2;89;0;0m\x1b[38;2;14;0;0m▀\x1b[48;2;134;1;1m\x1b[38;2;141;1;1m▀\x1b[48;2;31;13;7m\x1b[38;2;32;13;7m▀\x1b[48;2;10;15;8m \x1b[48;2;11;15;8m \x1b[48;2;11;14;7m\x1b[38;2;11;15;8m▀\x1b[48;2;10;14;7m\x1b[38;2;11;14;7m▀\x1b[48;2;53;29;22m\x1b[38;2;10;14;7m▀\x1b[48;2;127;46;41m\x1b[38;2;20;18;11m▀\x1b[48;2;158;51;47m\x1b[38;2;57;28;22m▀\x1b[48;2;166;52;48m\x1b[38;2;113;42;36m▀\x1b[48;2;167;52;48m\x1b[38;2;156;50;46m▀\x1b[48;2;164;52;48m\x1b[38;2;171;52;48m▀\x1b[48;2;146;48;44m\x1b[38;2;172;52;48m▀\x1b[48;2;102;38;33m▀\x1b[48;2;50;26;19m\x1b[38;2;161;51;46m▀\x1b[48;2;17;17;10m\x1b[38;2;126;44;38m▀\x1b[48;2;8;14;7m\x1b[38;2;71;31;25m▀\x1b[48;2;10;14;7m\x1b[38;2;27;19;13m▀\x1b[48;2;11;13;8m\x1b[38;2;10;14;7m▀\x1b[48;2;9;40;6m\x1b[38;2;10;13;7m▀\x1b[48;2;4;119;3m\x1b[38;2;11;20;7m▀\x1b[48;2;1;168;1m\x1b[38;2;8;63;5m▀\x1b[48;2;0;177;0m\x1b[38;2;3;130;2m▀\x1b[48;2;0;175;0m\x1b[38;2;1;171;1m▀\x1b[48;2;1;174;1m\x1b[38;2;0;176;0m▀\x1b[48;2;1;175;1m\x1b[38;2;1;174;1m▀\x1b[48;2;0;177;0m\x1b[38;2;0;176;0m▀\x1b[48;2;3;134;2m\x1b[38;2;2;158;1m▀\x1b[48;2;10;21;7m\x1b[38;2;9;38;6m▀\x1b[48;2;11;14;8m\x1b[38;2;11;13;8m▀\x1b[48;2;11;15;8m \x1b[48;2;10;15;8m \x1b[48;2;23;14;7m \x1b[48;2;127;2;1m\x1b[38;2;133;2;1m▀\x1b[48;2;176;0;0m\x1b[38;2;213;0;0m▀\x1b[48;2;109;0;0m\x1b[38;2;100;0;0m▀\x1b[m +\x1b[48;2;24;0;0m\x1b[38;2;157;0;0m▀\x1b[48;2;32;0;0m\x1b[38;2;165;0;0m▀\x1b[48;2;121;1;1m\x1b[38;2;128;1;1m▀\x1b[48;2;28;13;7m\x1b[38;2;30;13;7m▀\x1b[48;2;10;15;8m \x1b[48;2;11;15;8m \x1b[48;2;11;14;7m \x1b[48;2;9;15;7m \x1b[48;2;88;41;34m\x1b[38;2;91;41;34m▀\x1b[48;2;145;51;47m\x1b[38;2;163;53;49m▀\x1b[48;2;107;42;36m\x1b[38;2;161;52;48m▀\x1b[48;2;58;29;22m\x1b[38;2;155;51;47m▀\x1b[48;2;21;18;11m\x1b[38;2;128;45;40m▀\x1b[48;2;9;14;7m\x1b[38;2;79;33;27m▀\x1b[38;2;33;21;15m▀\x1b[48;2;11;14;7m\x1b[38;2;12;15;8m▀\x1b[48;2;11;15;8m\x1b[38;2;9;14;7m▀\x1b[38;2;10;14;7m▀ \x1b[48;2;11;12;8m\x1b[38;2;11;14;8m▀\x1b[48;2;8;54;6m\x1b[38;2;10;28;7m▀\x1b[48;2;6;93;4m\x1b[38;2;4;125;3m▀\x1b[48;2;2;152;1m\x1b[38;2;0;175;0m▀\x1b[48;2;0;176;0m▀\x1b[48;2;0;175;0m\x1b[38;2;1;174;1m▀\x1b[48;2;0;177;0m\x1b[38;2;1;175;1m▀\x1b[48;2;0;175;0m▀▀\x1b[48;2;1;162;1m\x1b[38;2;0;176;0m▀\x1b[48;2;9;47;6m\x1b[38;2;6;95;4m▀\x1b[48;2;11;13;8m \x1b[48;2;11;15;8m\x1b[38;2;11;14;8m▀ \x1b[48;2;10;15;8m \x1b[48;2;21;13;7m\x1b[38;2;22;13;7m▀\x1b[48;2;114;2;1m\x1b[38;2;121;2;1m▀\x1b[48;2;164;0;0m\x1b[38;2;170;0;0m▀\x1b[48;2;127;0;0m\x1b[38;2;118;0;0m▀\x1b[m +\x1b[48;2;14;0;0m\x1b[38;2;147;0;0m▀\x1b[48;2;183;0;0m\x1b[38;2;108;0;0m▀\x1b[48;2;107;1;1m\x1b[38;2;114;1;1m▀\x1b[48;2;26;13;7m\x1b[38;2;27;13;7m▀\x1b[48;2;10;15;8m \x1b[48;2;11;15;8m \x1b[38;2;11;14;7m▀ \x1b[48;2;10;14;7m\x1b[38;2;43;27;20m▀\x1b[48;2;9;14;7m\x1b[38;2;42;25;18m▀\x1b[48;2;11;14;7m\x1b[38;2;14;16;9m▀\x1b[48;2;11;15;8m\x1b[38;2;9;14;7m▀\x1b[38;2;10;14;7m▀\x1b[38;2;11;14;7m▀ \x1b[48;2;11;12;8m \x1b[48;2;9;49;6m\x1b[38;2;8;64;5m▀\x1b[48;2;1;166;1m\x1b[38;2;1;159;1m▀\x1b[48;2;0;175;0m\x1b[38;2;1;171;1m▀ \x1b[48;2;1;159;1m\x1b[38;2;1;167;1m▀\x1b[48;2;7;79;5m\x1b[38;2;4;122;3m▀\x1b[48;2;2;144;2m\x1b[38;2;2;158;1m▀\x1b[48;2;0;158;1m\x1b[38;2;0;177;0m▀\x1b[48;2;7;44;6m\x1b[38;2;4;112;3m▀\x1b[48;2;9;12;7m\x1b[38;2;11;17;7m▀\x1b[48;2;9;14;7m\x1b[38;2;11;14;8m▀\x1b[38;2;11;15;8m▀▀▀▀▀▀▀▀▀▀▀\x1b[48;2;11;14;7m▀\x1b[48;2;11;15;8m \x1b[48;2;10;15;8m \x1b[48;2;20;13;7m\x1b[38;2;21;13;7m▀\x1b[48;2;102;2;1m\x1b[38;2;108;2;1m▀\x1b[48;2;121;0;0m\x1b[38;2;127;0;0m▀\x1b[48;2;146;0;0m\x1b[38;2;136;0;0m▀\x1b[m +\x1b[48;2;3;0;0m\x1b[38;2;137;0;0m▀\x1b[48;2;173;0;0m\x1b[38;2;50;0;0m▀\x1b[48;2;93;1;1m\x1b[38;2;100;1;1m▀\x1b[48;2;24;13;7m\x1b[38;2;25;13;7m▀\x1b[48;2;10;15;8m \x1b[48;2;11;15;8m \x1b[48;2;11;14;7m\x1b[38;2;11;15;8m▀▀\x1b[48;2;17;14;7m\x1b[38;2;11;14;8m▀\x1b[48;2;49;12;7m\x1b[38;2;9;24;7m▀\x1b[48;2;62;54;4m\x1b[38;2;8;133;2m▀\x1b[48;2;7;159;1m\x1b[38;2;2;176;0m▀\x1b[48;2;0;175;0m \x1b[48;2;1;172;1m\x1b[38;2;0;175;0m▀\x1b[48;2;1;159;1m\x1b[38;2;0;173;1m▀\x1b[48;2;46;122;19m\x1b[38;2;1;176;0m▀\x1b[48;2;122;63;45m\x1b[38;2;45;111;18m▀\x1b[48;2;135;52;49m\x1b[38;2;75;36;31m▀\x1b[48;2;135;53;49m\x1b[38;2;74;36;30m▀▀▀▀▀▀▀▀▀▀▀\x1b[48;2;136;53;49m\x1b[38;2;75;37;31m▀\x1b[48;2;119;49;45m\x1b[38;2;66;34;28m▀\x1b[48;2;25;20;13m\x1b[38;2;18;18;11m▀\x1b[48;2;10;14;7m \x1b[48;2;11;15;8m \x1b[48;2;10;15;8m \x1b[48;2;19;13;7m \x1b[48;2;89;2;1m\x1b[38;2;95;2;1m▀\x1b[48;2;77;0;0m\x1b[38;2;83;0;0m▀\x1b[48;2;128;0;0m\x1b[38;2;119;0;0m▀\x1b[m +\x1b[48;2;60;0;0m\x1b[38;2;126;0;0m▀\x1b[48;2;182;0;0m\x1b[38;2;249;0;0m▀\x1b[48;2;83;1;1m\x1b[38;2;87;1;1m▀\x1b[48;2;22;13;7m\x1b[38;2;23;13;7m▀\x1b[48;2;10;15;8m \x1b[48;2;11;15;8m \x1b[48;2;11;14;7m\x1b[38;2;16;14;7m▀\x1b[48;2;14;14;7m\x1b[38;2;42;13;7m▀\x1b[48;2;58;13;6m\x1b[38;2;95;11;5m▀\x1b[48;2;34;13;7m\x1b[38;2;100;11;5m▀\x1b[48;2;9;14;7m\x1b[38;2;21;17;7m▀\x1b[48;2;11;12;8m\x1b[38;2;8;55;6m▀\x1b[38;2;7;75;5m▀\x1b[38;2;8;65;5m▀\x1b[48;2;11;13;8m\x1b[38;2;9;41;6m▀\x1b[48;2;12;15;8m\x1b[38;2;60;37;28m▀\x1b[38;2;90;42;37m▀\x1b[38;2;88;42;36m▀▀▀▀▀▀▀▀▀▀▀▀\x1b[38;2;89;42;37m▀\x1b[38;2;78;39;33m▀\x1b[48;2;11;15;8m\x1b[38;2;20;18;11m▀\x1b[48;2;11;14;7m\x1b[38;2;10;14;7m▀\x1b[48;2;11;15;8m \x1b[48;2;10;15;8m \x1b[48;2;18;13;7m \x1b[48;2;78;2;1m\x1b[38;2;83;2;1m▀\x1b[48;2;196;0;0m\x1b[38;2;40;0;0m▀\x1b[48;2;217;0;0m\x1b[38;2;137;0;0m▀\x1b[m +\x1b[48;2;227;0;0m\x1b[38;2;16;0;0m▀\x1b[48;2;116;0;0m\x1b[38;2;21;0;0m▀\x1b[48;2;79;1;1m\x1b[38;2;81;1;1m▀\x1b[48;2;22;13;7m \x1b[48;2;10;15;8m \x1b[48;2;11;15;8m \x1b[38;2;10;15;8m▀\x1b[48;2;10;15;8m\x1b[38;2;21;14;7m▀\x1b[48;2;11;15;8m\x1b[38;2;14;14;7m▀\x1b[38;2;11;14;7m▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ \x1b[48;2;10;15;8m \x1b[48;2;17;13;7m\x1b[38;2;18;13;7m▀\x1b[48;2;75;2;1m\x1b[38;2;76;2;1m▀\x1b[48;2;97;0;0m\x1b[38;2;34;0;0m▀\x1b[48;2;76;0;0m\x1b[38;2;147;0;0m▀\x1b[m +\x1b[48;2;161;0;0m\x1b[38;2;183;0;0m▀\x1b[48;2;49;0;0m\x1b[38;2;211;0;0m▀\x1b[48;2;75;1;1m\x1b[38;2;77;1;1m▀\x1b[48;2;21;13;7m \x1b[48;2;10;15;8m \x1b[48;2;11;15;8m \x1b[48;2;10;15;8m \x1b[48;2;17;13;7m \x1b[48;2;71;2;1m\x1b[38;2;73;2;1m▀\x1b[48;2;253;0;0m\x1b[38;2;159;0;0m▀\x1b[48;2;191;0;0m\x1b[38;2;5;0;0m▀\x1b[m +\x1b[48;2;110;161;100m\x1b[38;2;116;0;0m▀\x1b[48;2;9;205;205m\x1b[38;2;192;0;0m▀\x1b[48;2;78;0;0m\x1b[38;2;77;1;0m▀\x1b[48;2;66;3;1m\x1b[38;2;30;11;6m▀\x1b[48;2;42;8;4m\x1b[38;2;9;15;8m▀\x1b[48;2;39;8;4m\x1b[38;2;10;15;8m▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\x1b[48;2;40;8;4m▀\x1b[48;2;39;8;4m▀▀▀▀▀▀▀\x1b[48;2;40;8;4m▀▀▀\x1b[48;2;39;8;4m▀\x1b[48;2;40;8;4m▀\x1b[48;2;39;8;4m▀\x1b[48;2;41;8;4m\x1b[38;2;9;15;8m▀\x1b[48;2;62;4;2m\x1b[38;2;24;13;7m▀\x1b[48;2;78;0;0m\x1b[38;2;74;1;1m▀\x1b[48;2;221;222;0m\x1b[38;2;59;0;0m▀\x1b[48;2;67;199;133m\x1b[38;2;85;0;0m▀\x1b[m +\x1b[48;2;0;0;0m\x1b[38;2;143;233;149m▀\x1b[48;2;108;184;254m\x1b[38;2;213;6;76m▀\x1b[48;2;197;183;82m\x1b[38;2;76;0;0m▀\x1b[48;2;154;157;0m▀\x1b[48;2;96;0;0m▀\x1b[48;2;253;0;0m▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀\x1b[48;2;226;0;0m▀\x1b[48;2;255;127;255m▀\x1b[48;2;84;36;66m\x1b[38;2;64;247;251m▀\x1b[48;2;0;0;0m\x1b[38;2;18;76;210m▀\x1b[m +\x1b[48;2;0;0;0m \x1b[m +\x1b[48;2;0;0;0m \x1b[m +""" + ) +) diff --git a/examples/print-text/pygments-tokens.py b/examples/print-text/pygments-tokens.py new file mode 100755 index 0000000..3470e8e --- /dev/null +++ b/examples/print-text/pygments-tokens.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python +""" +Printing a list of Pygments (Token, text) tuples, +or an output of a Pygments lexer. +""" +import pygments +from pygments.lexers.python import PythonLexer +from pygments.token import Token + +from prompt_toolkit import print_formatted_text +from prompt_toolkit.formatted_text import PygmentsTokens +from prompt_toolkit.styles import Style + + +def main(): + # Printing a manually constructed list of (Token, text) tuples. + text = [ + (Token.Keyword, "print"), + (Token.Punctuation, "("), + (Token.Literal.String.Double, '"'), + (Token.Literal.String.Double, "hello"), + (Token.Literal.String.Double, '"'), + (Token.Punctuation, ")"), + (Token.Text, "\n"), + ] + + print_formatted_text(PygmentsTokens(text)) + + # Printing the output of a pygments lexer. + tokens = list(pygments.lex('print("Hello")', lexer=PythonLexer())) + print_formatted_text(PygmentsTokens(tokens)) + + # With a custom style. + style = Style.from_dict( + { + "pygments.keyword": "underline", + "pygments.literal.string": "bg:#00ff00 #ffffff", + } + ) + print_formatted_text(PygmentsTokens(tokens), style=style) + + +if __name__ == "__main__": + main() diff --git a/examples/print-text/true-color-demo.py b/examples/print-text/true-color-demo.py new file mode 100755 index 0000000..a241006 --- /dev/null +++ b/examples/print-text/true-color-demo.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +""" +Demonstration of all the ANSI colors. +""" +from prompt_toolkit import print_formatted_text +from prompt_toolkit.formatted_text import HTML, FormattedText +from prompt_toolkit.output import ColorDepth + +print = print_formatted_text + + +def main(): + print(HTML("\n<u>True color test.</u>")) + + for template in [ + "bg:#{0:02x}0000", # Red. + "bg:#00{0:02x}00", # Green. + "bg:#0000{0:02x}", # Blue. + "bg:#{0:02x}{0:02x}00", # Yellow. + "bg:#{0:02x}00{0:02x}", # Magenta. + "bg:#00{0:02x}{0:02x}", # Cyan. + "bg:#{0:02x}{0:02x}{0:02x}", # Gray. + ]: + fragments = [] + for i in range(0, 256, 4): + fragments.append((template.format(i), " ")) + + print(FormattedText(fragments), color_depth=ColorDepth.DEPTH_4_BIT) + print(FormattedText(fragments), color_depth=ColorDepth.DEPTH_8_BIT) + print(FormattedText(fragments), color_depth=ColorDepth.DEPTH_24_BIT) + print() + + +if __name__ == "__main__": + main() diff --git a/examples/progress-bar/a-lot-of-parallel-tasks.py b/examples/progress-bar/a-lot-of-parallel-tasks.py new file mode 100755 index 0000000..31110ac --- /dev/null +++ b/examples/progress-bar/a-lot-of-parallel-tasks.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python +""" +More complex demonstration of what's possible with the progress bar. +""" +import random +import threading +import time + +from prompt_toolkit import HTML +from prompt_toolkit.shortcuts import ProgressBar + + +def main(): + with ProgressBar( + title=HTML("<b>Example of many parallel tasks.</b>"), + bottom_toolbar=HTML("<b>[Control-L]</b> clear <b>[Control-C]</b> abort"), + ) as pb: + + def run_task(label, total, sleep_time): + """Complete a normal run.""" + for i in pb(range(total), label=label): + time.sleep(sleep_time) + + def stop_task(label, total, sleep_time): + """Stop at some random index. + + Breaking out of iteration at some stop index mimics how progress + bars behave in cases where errors are raised. + """ + stop_i = random.randrange(total) + bar = pb(range(total), label=label) + for i in bar: + if stop_i == i: + bar.label = f"{label} BREAK" + break + time.sleep(sleep_time) + + threads = [] + + for i in range(160): + label = "Task %i" % i + total = random.randrange(50, 200) + sleep_time = random.randrange(5, 20) / 100.0 + + threads.append( + threading.Thread( + target=random.choice((run_task, stop_task)), + args=(label, total, sleep_time), + ) + ) + + for t in threads: + t.daemon = True + t.start() + + # Wait for the threads to finish. We use a timeout for the join() call, + # because on Windows, join cannot be interrupted by Control-C or any other + # signal. + for t in threads: + while t.is_alive(): + t.join(timeout=0.5) + + +if __name__ == "__main__": + main() diff --git a/examples/progress-bar/colored-title-and-label.py b/examples/progress-bar/colored-title-and-label.py new file mode 100755 index 0000000..0b5e73a --- /dev/null +++ b/examples/progress-bar/colored-title-and-label.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +""" +A progress bar that displays a formatted title above the progress bar and has a +colored label. +""" +import time + +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.shortcuts import ProgressBar + + +def main(): + title = HTML('Downloading <style bg="yellow" fg="black">4 files...</style>') + label = HTML("<ansired>some file</ansired>: ") + + with ProgressBar(title=title) as pb: + for i in pb(range(800), label=label): + time.sleep(0.01) + + +if __name__ == "__main__": + main() diff --git a/examples/progress-bar/custom-key-bindings.py b/examples/progress-bar/custom-key-bindings.py new file mode 100755 index 0000000..f700811 --- /dev/null +++ b/examples/progress-bar/custom-key-bindings.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python +""" +A very simple progress bar which keep track of the progress as we consume an +iterator. +""" +import os +import signal +import time + +from prompt_toolkit import HTML +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.patch_stdout import patch_stdout +from prompt_toolkit.shortcuts import ProgressBar + + +def main(): + bottom_toolbar = HTML( + ' <b>[f]</b> Print "f" <b>[q]</b> Abort <b>[x]</b> Send Control-C.' + ) + + # Create custom key bindings first. + kb = KeyBindings() + cancel = [False] + + @kb.add("f") + def _(event): + print("You pressed `f`.") + + @kb.add("q") + def _(event): + "Quit by setting cancel flag." + cancel[0] = True + + @kb.add("x") + def _(event): + "Quit by sending SIGINT to the main thread." + os.kill(os.getpid(), signal.SIGINT) + + # Use `patch_stdout`, to make sure that prints go above the + # application. + with patch_stdout(): + with ProgressBar(key_bindings=kb, bottom_toolbar=bottom_toolbar) as pb: + for i in pb(range(800)): + time.sleep(0.01) + + if cancel[0]: + break + + +if __name__ == "__main__": + main() diff --git a/examples/progress-bar/many-parallel-tasks.py b/examples/progress-bar/many-parallel-tasks.py new file mode 100755 index 0000000..dc34ef2 --- /dev/null +++ b/examples/progress-bar/many-parallel-tasks.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python +""" +More complex demonstration of what's possible with the progress bar. +""" +import threading +import time + +from prompt_toolkit import HTML +from prompt_toolkit.shortcuts import ProgressBar + + +def main(): + with ProgressBar( + title=HTML("<b>Example of many parallel tasks.</b>"), + bottom_toolbar=HTML("<b>[Control-L]</b> clear <b>[Control-C]</b> abort"), + ) as pb: + + def run_task(label, total, sleep_time): + for i in pb(range(total), label=label): + time.sleep(sleep_time) + + threads = [ + threading.Thread(target=run_task, args=("First task", 50, 0.1)), + threading.Thread(target=run_task, args=("Second task", 100, 0.1)), + threading.Thread(target=run_task, args=("Third task", 8, 3)), + threading.Thread(target=run_task, args=("Fourth task", 200, 0.1)), + threading.Thread(target=run_task, args=("Fifth task", 40, 0.2)), + threading.Thread(target=run_task, args=("Sixth task", 220, 0.1)), + threading.Thread(target=run_task, args=("Seventh task", 85, 0.05)), + threading.Thread(target=run_task, args=("Eight task", 200, 0.05)), + ] + + for t in threads: + t.daemon = True + t.start() + + # Wait for the threads to finish. We use a timeout for the join() call, + # because on Windows, join cannot be interrupted by Control-C or any other + # signal. + for t in threads: + while t.is_alive(): + t.join(timeout=0.5) + + +if __name__ == "__main__": + main() diff --git a/examples/progress-bar/nested-progress-bars.py b/examples/progress-bar/nested-progress-bars.py new file mode 100755 index 0000000..1a1e706 --- /dev/null +++ b/examples/progress-bar/nested-progress-bars.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +""" +Example of nested progress bars. +""" +import time + +from prompt_toolkit import HTML +from prompt_toolkit.shortcuts import ProgressBar + + +def main(): + with ProgressBar( + title=HTML('<b fg="#aa00ff">Nested progress bars</b>'), + bottom_toolbar=HTML(" <b>[Control-L]</b> clear <b>[Control-C]</b> abort"), + ) as pb: + for i in pb(range(6), label="Main task"): + for j in pb(range(200), label=f"Subtask <{i + 1}>", remove_when_done=True): + time.sleep(0.01) + + +if __name__ == "__main__": + main() diff --git a/examples/progress-bar/scrolling-task-name.py b/examples/progress-bar/scrolling-task-name.py new file mode 100755 index 0000000..bce155f --- /dev/null +++ b/examples/progress-bar/scrolling-task-name.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +""" +A very simple progress bar where the name of the task scrolls, because it's too long. +iterator. +""" +import time + +from prompt_toolkit.shortcuts import ProgressBar + + +def main(): + with ProgressBar( + title="Scrolling task name (make sure the window is not too big)." + ) as pb: + for i in pb( + range(800), + label="This is a very very very long task that requires horizontal scrolling ...", + ): + time.sleep(0.01) + + +if __name__ == "__main__": + main() diff --git a/examples/progress-bar/simple-progress-bar.py b/examples/progress-bar/simple-progress-bar.py new file mode 100755 index 0000000..c8776e5 --- /dev/null +++ b/examples/progress-bar/simple-progress-bar.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python +""" +A very simple progress bar which keep track of the progress as we consume an +iterator. +""" +import time + +from prompt_toolkit.shortcuts import ProgressBar + + +def main(): + with ProgressBar() as pb: + for i in pb(range(800)): + time.sleep(0.01) + + +if __name__ == "__main__": + main() diff --git a/examples/progress-bar/styled-1.py b/examples/progress-bar/styled-1.py new file mode 100755 index 0000000..d972e55 --- /dev/null +++ b/examples/progress-bar/styled-1.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python +""" +A very simple progress bar which keep track of the progress as we consume an +iterator. +""" +import time + +from prompt_toolkit.shortcuts import ProgressBar +from prompt_toolkit.styles import Style + +style = Style.from_dict( + { + "title": "#4444ff underline", + "label": "#ff4400 bold", + "percentage": "#00ff00", + "bar-a": "bg:#00ff00 #004400", + "bar-b": "bg:#00ff00 #000000", + "bar-c": "#000000 underline", + "current": "#448844", + "total": "#448844", + "time-elapsed": "#444488", + "time-left": "bg:#88ff88 #000000", + } +) + + +def main(): + with ProgressBar( + style=style, title="Progress bar example with custom styling." + ) as pb: + for i in pb(range(1600), label="Downloading..."): + time.sleep(0.01) + + +if __name__ == "__main__": + main() diff --git a/examples/progress-bar/styled-2.py b/examples/progress-bar/styled-2.py new file mode 100755 index 0000000..15c57d4 --- /dev/null +++ b/examples/progress-bar/styled-2.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python +""" +A very simple progress bar which keep track of the progress as we consume an +iterator. +""" +import time + +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.shortcuts import ProgressBar +from prompt_toolkit.shortcuts.progress_bar import formatters +from prompt_toolkit.styles import Style + +style = Style.from_dict( + { + "progressbar title": "#0000ff", + "item-title": "#ff4400 underline", + "percentage": "#00ff00", + "bar-a": "bg:#00ff00 #004400", + "bar-b": "bg:#00ff00 #000000", + "bar-c": "bg:#000000 #000000", + "tildes": "#444488", + "time-left": "bg:#88ff88 #ffffff", + "spinning-wheel": "bg:#ffff00 #000000", + } +) + + +def main(): + custom_formatters = [ + formatters.Label(), + formatters.Text(" "), + formatters.SpinningWheel(), + formatters.Text(" "), + formatters.Text(HTML("<tildes>~~~</tildes>")), + formatters.Bar(sym_a="#", sym_b="#", sym_c="."), + formatters.Text(" left: "), + formatters.TimeLeft(), + ] + with ProgressBar( + title="Progress bar example with custom formatter.", + formatters=custom_formatters, + style=style, + ) as pb: + for i in pb(range(20), label="Downloading..."): + time.sleep(1) + + +if __name__ == "__main__": + main() diff --git a/examples/progress-bar/styled-apt-get-install.py b/examples/progress-bar/styled-apt-get-install.py new file mode 100755 index 0000000..bafe70b --- /dev/null +++ b/examples/progress-bar/styled-apt-get-install.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python +""" +Styled just like an apt-get installation. +""" +import time + +from prompt_toolkit.shortcuts import ProgressBar +from prompt_toolkit.shortcuts.progress_bar import formatters +from prompt_toolkit.styles import Style + +style = Style.from_dict( + { + "label": "bg:#ffff00 #000000", + "percentage": "bg:#ffff00 #000000", + "current": "#448844", + "bar": "", + } +) + + +def main(): + custom_formatters = [ + formatters.Label(), + formatters.Text(": [", style="class:percentage"), + formatters.Percentage(), + formatters.Text("]", style="class:percentage"), + formatters.Text(" "), + formatters.Bar(sym_a="#", sym_b="#", sym_c="."), + formatters.Text(" "), + ] + + with ProgressBar(style=style, formatters=custom_formatters) as pb: + for i in pb(range(1600), label="Installing"): + time.sleep(0.01) + + +if __name__ == "__main__": + main() diff --git a/examples/progress-bar/styled-rainbow.py b/examples/progress-bar/styled-rainbow.py new file mode 100755 index 0000000..b46e949 --- /dev/null +++ b/examples/progress-bar/styled-rainbow.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +""" +A simple progress bar, visualized with rainbow colors (for fun). +""" +import time + +from prompt_toolkit.output import ColorDepth +from prompt_toolkit.shortcuts import ProgressBar +from prompt_toolkit.shortcuts.progress_bar import formatters +from prompt_toolkit.shortcuts.prompt import confirm + + +def main(): + true_color = confirm("Yes true colors? (y/n) ") + + custom_formatters = [ + formatters.Label(), + formatters.Text(" "), + formatters.Rainbow(formatters.Bar()), + formatters.Text(" left: "), + formatters.Rainbow(formatters.TimeLeft()), + ] + + if true_color: + color_depth = ColorDepth.DEPTH_24_BIT + else: + color_depth = ColorDepth.DEPTH_8_BIT + + with ProgressBar(formatters=custom_formatters, color_depth=color_depth) as pb: + for i in pb(range(20), label="Downloading..."): + time.sleep(1) + + +if __name__ == "__main__": + main() diff --git a/examples/progress-bar/styled-tqdm-1.py b/examples/progress-bar/styled-tqdm-1.py new file mode 100755 index 0000000..9484ac0 --- /dev/null +++ b/examples/progress-bar/styled-tqdm-1.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python +""" +Styled similar to tqdm, another progress bar implementation in Python. + +See: https://github.com/noamraph/tqdm +""" +import time + +from prompt_toolkit.shortcuts import ProgressBar +from prompt_toolkit.shortcuts.progress_bar import formatters +from prompt_toolkit.styles import Style + +style = Style.from_dict({"": "cyan"}) + + +def main(): + custom_formatters = [ + formatters.Label(suffix=": "), + formatters.Bar(start="|", end="|", sym_a="#", sym_b="#", sym_c="-"), + formatters.Text(" "), + formatters.Progress(), + formatters.Text(" "), + formatters.Percentage(), + formatters.Text(" [elapsed: "), + formatters.TimeElapsed(), + formatters.Text(" left: "), + formatters.TimeLeft(), + formatters.Text(", "), + formatters.IterationsPerSecond(), + formatters.Text(" iters/sec]"), + formatters.Text(" "), + ] + + with ProgressBar(style=style, formatters=custom_formatters) as pb: + for i in pb(range(1600), label="Installing"): + time.sleep(0.01) + + +if __name__ == "__main__": + main() diff --git a/examples/progress-bar/styled-tqdm-2.py b/examples/progress-bar/styled-tqdm-2.py new file mode 100755 index 0000000..0e66e90 --- /dev/null +++ b/examples/progress-bar/styled-tqdm-2.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python +""" +Styled similar to tqdm, another progress bar implementation in Python. + +See: https://github.com/noamraph/tqdm +""" +import time + +from prompt_toolkit.shortcuts import ProgressBar +from prompt_toolkit.shortcuts.progress_bar import formatters +from prompt_toolkit.styles import Style + +style = Style.from_dict({"bar-a": "reverse"}) + + +def main(): + custom_formatters = [ + formatters.Label(suffix=": "), + formatters.Percentage(), + formatters.Bar(start="|", end="|", sym_a=" ", sym_b=" ", sym_c=" "), + formatters.Text(" "), + formatters.Progress(), + formatters.Text(" ["), + formatters.TimeElapsed(), + formatters.Text("<"), + formatters.TimeLeft(), + formatters.Text(", "), + formatters.IterationsPerSecond(), + formatters.Text("it/s]"), + ] + + with ProgressBar(style=style, formatters=custom_formatters) as pb: + for i in pb(range(1600), label="Installing"): + time.sleep(0.01) + + +if __name__ == "__main__": + main() diff --git a/examples/progress-bar/two-tasks.py b/examples/progress-bar/two-tasks.py new file mode 100755 index 0000000..c78604e --- /dev/null +++ b/examples/progress-bar/two-tasks.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python +""" +Two progress bars that run in parallel. +""" +import threading +import time + +from prompt_toolkit.shortcuts import ProgressBar + + +def main(): + with ProgressBar() as pb: + # Two parallal tasks. + def task_1(): + for i in pb(range(100)): + time.sleep(0.05) + + def task_2(): + for i in pb(range(150)): + time.sleep(0.08) + + # Start threads. + t1 = threading.Thread(target=task_1) + t2 = threading.Thread(target=task_2) + t1.daemon = True + t2.daemon = True + t1.start() + t2.start() + + # Wait for the threads to finish. We use a timeout for the join() call, + # because on Windows, join cannot be interrupted by Control-C or any other + # signal. + for t in [t1, t2]: + while t.is_alive(): + t.join(timeout=0.5) + + +if __name__ == "__main__": + main() diff --git a/examples/progress-bar/unknown-length.py b/examples/progress-bar/unknown-length.py new file mode 100755 index 0000000..e39ac39 --- /dev/null +++ b/examples/progress-bar/unknown-length.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python +""" +A very simple progress bar which keep track of the progress as we consume an +iterator. +""" +import time + +from prompt_toolkit.shortcuts import ProgressBar + + +def data(): + """ + A generator that produces items. len() doesn't work here, so the progress + bar can't estimate the time it will take. + """ + yield from range(1000) + + +def main(): + with ProgressBar() as pb: + for i in pb(data()): + time.sleep(0.1) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/accept-default.py b/examples/prompts/accept-default.py new file mode 100644 index 0000000..311ef46 --- /dev/null +++ b/examples/prompts/accept-default.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python +""" +Example of `accept_default`, a way to automatically accept the input that the +user typed without allowing him/her to edit it. + +This should display the prompt with all the formatting like usual, but not +allow any editing. +""" +from prompt_toolkit import HTML, prompt + +if __name__ == "__main__": + answer = prompt( + HTML("<b>Type <u>some input</u>: </b>"), accept_default=True, default="test" + ) + + print("You said: %s" % answer) diff --git a/examples/prompts/asyncio-prompt.py b/examples/prompts/asyncio-prompt.py new file mode 100755 index 0000000..32a1481 --- /dev/null +++ b/examples/prompts/asyncio-prompt.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python +""" +This is an example of how to prompt inside an application that uses the asyncio +eventloop. The ``prompt_toolkit`` library will make sure that when other +coroutines are writing to stdout, they write above the prompt, not destroying +the input line. +This example does several things: + 1. It starts a simple coroutine, printing a counter to stdout every second. + 2. It starts a simple input/echo app loop which reads from stdin. +Very important is the following patch. If you are passing stdin by reference to +other parts of the code, make sure that this patch is applied as early as +possible. :: + sys.stdout = app.stdout_proxy() +""" + +import asyncio + +from prompt_toolkit.patch_stdout import patch_stdout +from prompt_toolkit.shortcuts import PromptSession + + +async def print_counter(): + """ + Coroutine that prints counters. + """ + try: + i = 0 + while True: + print("Counter: %i" % i) + i += 1 + await asyncio.sleep(3) + except asyncio.CancelledError: + print("Background task cancelled.") + + +async def interactive_shell(): + """ + Like `interactive_shell`, but doing things manual. + """ + # Create Prompt. + session = PromptSession("Say something: ") + + # Run echo loop. Read text from stdin, and reply it back. + while True: + try: + result = await session.prompt_async() + print(f'You said: "{result}"') + except (EOFError, KeyboardInterrupt): + return + + +async def main(): + with patch_stdout(): + background_task = asyncio.create_task(print_counter()) + try: + await interactive_shell() + finally: + background_task.cancel() + print("Quitting event loop. Bye.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/prompts/auto-completion/autocomplete-with-control-space.py b/examples/prompts/auto-completion/autocomplete-with-control-space.py new file mode 100755 index 0000000..61160a3 --- /dev/null +++ b/examples/prompts/auto-completion/autocomplete-with-control-space.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python +""" +Example of using the control-space key binding for auto completion. +""" +from prompt_toolkit import prompt +from prompt_toolkit.completion import WordCompleter +from prompt_toolkit.key_binding import KeyBindings + +animal_completer = WordCompleter( + [ + "alligator", + "ant", + "ape", + "bat", + "bear", + "beaver", + "bee", + "bison", + "butterfly", + "cat", + "chicken", + "crocodile", + "dinosaur", + "dog", + "dolphin", + "dove", + "duck", + "eagle", + "elephant", + "fish", + "goat", + "gorilla", + "kangaroo", + "leopard", + "lion", + "mouse", + "rabbit", + "rat", + "snake", + "spider", + "turkey", + "turtle", + ], + ignore_case=True, +) + + +kb = KeyBindings() + + +@kb.add("c-space") +def _(event): + """ + Start auto completion. If the menu is showing already, select the next + completion. + """ + b = event.app.current_buffer + if b.complete_state: + b.complete_next() + else: + b.start_completion(select_first=False) + + +def main(): + text = prompt( + "Give some animals: ", + completer=animal_completer, + complete_while_typing=False, + key_bindings=kb, + ) + print("You said: %s" % text) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/auto-completion/autocompletion-like-readline.py b/examples/prompts/auto-completion/autocompletion-like-readline.py new file mode 100755 index 0000000..613d3e7 --- /dev/null +++ b/examples/prompts/auto-completion/autocompletion-like-readline.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python +""" +Autocompletion example that displays the autocompletions like readline does by +binding a custom handler to the Tab key. +""" +from prompt_toolkit.completion import WordCompleter +from prompt_toolkit.shortcuts import CompleteStyle, prompt + +animal_completer = WordCompleter( + [ + "alligator", + "ant", + "ape", + "bat", + "bear", + "beaver", + "bee", + "bison", + "butterfly", + "cat", + "chicken", + "crocodile", + "dinosaur", + "dog", + "dolphin", + "dove", + "duck", + "eagle", + "elephant", + "fish", + "goat", + "gorilla", + "kangaroo", + "leopard", + "lion", + "mouse", + "rabbit", + "rat", + "snake", + "spider", + "turkey", + "turtle", + ], + ignore_case=True, +) + + +def main(): + text = prompt( + "Give some animals: ", + completer=animal_completer, + complete_style=CompleteStyle.READLINE_LIKE, + ) + print("You said: %s" % text) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/auto-completion/autocompletion.py b/examples/prompts/auto-completion/autocompletion.py new file mode 100755 index 0000000..fc9dda0 --- /dev/null +++ b/examples/prompts/auto-completion/autocompletion.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python +""" +Autocompletion example. + +Press [Tab] to complete the current word. +- The first Tab press fills in the common part of all completions + and shows all the completions. (In the menu) +- Any following tab press cycles through all the possible completions. +""" +from prompt_toolkit import prompt +from prompt_toolkit.completion import WordCompleter + +animal_completer = WordCompleter( + [ + "alligator", + "ant", + "ape", + "bat", + "bear", + "beaver", + "bee", + "bison", + "butterfly", + "cat", + "chicken", + "crocodile", + "dinosaur", + "dog", + "dolphin", + "dove", + "duck", + "eagle", + "elephant", + "fish", + "goat", + "gorilla", + "kangaroo", + "leopard", + "lion", + "mouse", + "rabbit", + "rat", + "snake", + "spider", + "turkey", + "turtle", + ], + ignore_case=True, +) + + +def main(): + text = prompt( + "Give some animals: ", completer=animal_completer, complete_while_typing=False + ) + print("You said: %s" % text) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/auto-completion/colored-completions-with-formatted-text.py b/examples/prompts/auto-completion/colored-completions-with-formatted-text.py new file mode 100755 index 0000000..8a89c7a --- /dev/null +++ b/examples/prompts/auto-completion/colored-completions-with-formatted-text.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python +""" +Demonstration of a custom completer class and the possibility of styling +completions independently by passing formatted text objects to the "display" +and "display_meta" arguments of "Completion". +""" +from prompt_toolkit.completion import Completer, Completion +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.shortcuts import CompleteStyle, prompt + +animals = [ + "alligator", + "ant", + "ape", + "bat", + "bear", + "beaver", + "bee", + "bison", + "butterfly", + "cat", + "chicken", + "crocodile", + "dinosaur", + "dog", + "dolphin", + "dove", + "duck", + "eagle", + "elephant", +] + +animal_family = { + "alligator": "reptile", + "ant": "insect", + "ape": "mammal", + "bat": "mammal", + "bear": "mammal", + "beaver": "mammal", + "bee": "insect", + "bison": "mammal", + "butterfly": "insect", + "cat": "mammal", + "chicken": "bird", + "crocodile": "reptile", + "dinosaur": "reptile", + "dog": "mammal", + "dolphin": "mammal", + "dove": "bird", + "duck": "bird", + "eagle": "bird", + "elephant": "mammal", +} + +family_colors = { + "mammal": "ansimagenta", + "insect": "ansigreen", + "reptile": "ansired", + "bird": "ansiyellow", +} + +meta = { + "alligator": HTML( + "An <ansired>alligator</ansired> is a <u>crocodilian</u> in the genus Alligator of the family Alligatoridae." + ), + "ant": HTML( + "<ansired>Ants</ansired> are eusocial <u>insects</u> of the family Formicidae." + ), + "ape": HTML( + "<ansired>Apes</ansired> (Hominoidea) are a branch of Old World tailless anthropoid catarrhine <u>primates</u>." + ), + "bat": HTML("<ansired>Bats</ansired> are mammals of the order <u>Chiroptera</u>."), + "bee": HTML( + "<ansired>Bees</ansired> are flying <u>insects</u> closely related to wasps and ants." + ), + "beaver": HTML( + "The <ansired>beaver</ansired> (genus Castor) is a large, primarily <u>nocturnal</u>, semiaquatic <u>rodent</u>." + ), + "bear": HTML( + "<ansired>Bears</ansired> are carnivoran <u>mammals</u> of the family Ursidae." + ), + "butterfly": HTML( + "<ansiblue>Butterflies</ansiblue> are <u>insects</u> in the macrolepidopteran clade Rhopalocera from the order Lepidoptera." + ), + # ... +} + + +class AnimalCompleter(Completer): + def get_completions(self, document, complete_event): + word = document.get_word_before_cursor() + for animal in animals: + if animal.startswith(word): + if animal in animal_family: + family = animal_family[animal] + family_color = family_colors.get(family, "default") + + display = HTML( + "%s<b>:</b> <ansired>(<" + + family_color + + ">%s</" + + family_color + + ">)</ansired>" + ) % (animal, family) + else: + display = animal + + yield Completion( + animal, + start_position=-len(word), + display=display, + display_meta=meta.get(animal), + ) + + +def main(): + # Simple completion menu. + print("(The completion menu displays colors.)") + prompt("Type an animal: ", completer=AnimalCompleter()) + + # Multi-column menu. + prompt( + "Type an animal: ", + completer=AnimalCompleter(), + complete_style=CompleteStyle.MULTI_COLUMN, + ) + + # Readline-like + prompt( + "Type an animal: ", + completer=AnimalCompleter(), + complete_style=CompleteStyle.READLINE_LIKE, + ) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/auto-completion/colored-completions.py b/examples/prompts/auto-completion/colored-completions.py new file mode 100755 index 0000000..9c5cba3 --- /dev/null +++ b/examples/prompts/auto-completion/colored-completions.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python +""" +Demonstration of a custom completer class and the possibility of styling +completions independently. +""" +from prompt_toolkit.completion import Completer, Completion +from prompt_toolkit.output.color_depth import ColorDepth +from prompt_toolkit.shortcuts import CompleteStyle, prompt + +colors = [ + "red", + "blue", + "green", + "orange", + "purple", + "yellow", + "cyan", + "magenta", + "pink", +] + + +class ColorCompleter(Completer): + def get_completions(self, document, complete_event): + word = document.get_word_before_cursor() + for color in colors: + if color.startswith(word): + yield Completion( + color, + start_position=-len(word), + style="fg:" + color, + selected_style="fg:white bg:" + color, + ) + + +def main(): + # Simple completion menu. + print("(The completion menu displays colors.)") + prompt("Type a color: ", completer=ColorCompleter()) + + # Multi-column menu. + prompt( + "Type a color: ", + completer=ColorCompleter(), + complete_style=CompleteStyle.MULTI_COLUMN, + ) + + # Readline-like + prompt( + "Type a color: ", + completer=ColorCompleter(), + complete_style=CompleteStyle.READLINE_LIKE, + ) + + # Prompt with true color output. + message = [ + ("#cc2244", "T"), + ("#bb4444", "r"), + ("#996644", "u"), + ("#cc8844", "e "), + ("#ccaa44", "C"), + ("#bbaa44", "o"), + ("#99aa44", "l"), + ("#778844", "o"), + ("#55aa44", "r "), + ("#33aa44", "p"), + ("#11aa44", "r"), + ("#11aa66", "o"), + ("#11aa88", "m"), + ("#11aaaa", "p"), + ("#11aacc", "t"), + ("#11aaee", ": "), + ] + prompt(message, completer=ColorCompleter(), color_depth=ColorDepth.TRUE_COLOR) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/auto-completion/combine-multiple-completers.py b/examples/prompts/auto-completion/combine-multiple-completers.py new file mode 100755 index 0000000..511988b --- /dev/null +++ b/examples/prompts/auto-completion/combine-multiple-completers.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python +""" +Example of multiple individual completers that are combined into one. +""" +from prompt_toolkit import prompt +from prompt_toolkit.completion import WordCompleter, merge_completers + +animal_completer = WordCompleter( + [ + "alligator", + "ant", + "ape", + "bat", + "bear", + "beaver", + "bee", + "bison", + "butterfly", + "cat", + "chicken", + "crocodile", + "dinosaur", + "dog", + "dolphin", + "dove", + "duck", + "eagle", + "elephant", + "fish", + "goat", + "gorilla", + "kangaroo", + "leopard", + "lion", + "mouse", + "rabbit", + "rat", + "snake", + "spider", + "turkey", + "turtle", + ], + ignore_case=True, +) + +color_completer = WordCompleter( + [ + "red", + "green", + "blue", + "yellow", + "white", + "black", + "orange", + "gray", + "pink", + "purple", + "cyan", + "magenta", + "violet", + ], + ignore_case=True, +) + + +def main(): + completer = merge_completers([animal_completer, color_completer]) + + text = prompt( + "Give some animals: ", completer=completer, complete_while_typing=False + ) + print("You said: %s" % text) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/auto-completion/fuzzy-custom-completer.py b/examples/prompts/auto-completion/fuzzy-custom-completer.py new file mode 100755 index 0000000..fd9a7d7 --- /dev/null +++ b/examples/prompts/auto-completion/fuzzy-custom-completer.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python +""" +Demonstration of a custom completer wrapped in a `FuzzyCompleter` for fuzzy +matching. +""" +from prompt_toolkit.completion import Completer, Completion, FuzzyCompleter +from prompt_toolkit.shortcuts import CompleteStyle, prompt + +colors = [ + "red", + "blue", + "green", + "orange", + "purple", + "yellow", + "cyan", + "magenta", + "pink", +] + + +class ColorCompleter(Completer): + def get_completions(self, document, complete_event): + word = document.get_word_before_cursor() + for color in colors: + if color.startswith(word): + yield Completion( + color, + start_position=-len(word), + style="fg:" + color, + selected_style="fg:white bg:" + color, + ) + + +def main(): + # Simple completion menu. + print("(The completion menu displays colors.)") + prompt("Type a color: ", completer=FuzzyCompleter(ColorCompleter())) + + # Multi-column menu. + prompt( + "Type a color: ", + completer=FuzzyCompleter(ColorCompleter()), + complete_style=CompleteStyle.MULTI_COLUMN, + ) + + # Readline-like + prompt( + "Type a color: ", + completer=FuzzyCompleter(ColorCompleter()), + complete_style=CompleteStyle.READLINE_LIKE, + ) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/auto-completion/fuzzy-word-completer.py b/examples/prompts/auto-completion/fuzzy-word-completer.py new file mode 100755 index 0000000..329c0c1 --- /dev/null +++ b/examples/prompts/auto-completion/fuzzy-word-completer.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python +""" +Autocompletion example. + +Press [Tab] to complete the current word. +- The first Tab press fills in the common part of all completions + and shows all the completions. (In the menu) +- Any following tab press cycles through all the possible completions. +""" +from prompt_toolkit.completion import FuzzyWordCompleter +from prompt_toolkit.shortcuts import prompt + +animal_completer = FuzzyWordCompleter( + [ + "alligator", + "ant", + "ape", + "bat", + "bear", + "beaver", + "bee", + "bison", + "butterfly", + "cat", + "chicken", + "crocodile", + "dinosaur", + "dog", + "dolphin", + "dove", + "duck", + "eagle", + "elephant", + "fish", + "goat", + "gorilla", + "kangaroo", + "leopard", + "lion", + "mouse", + "rabbit", + "rat", + "snake", + "spider", + "turkey", + "turtle", + ] +) + + +def main(): + text = prompt( + "Give some animals: ", completer=animal_completer, complete_while_typing=True + ) + print("You said: %s" % text) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/auto-completion/multi-column-autocompletion-with-meta.py b/examples/prompts/auto-completion/multi-column-autocompletion-with-meta.py new file mode 100755 index 0000000..5ba3ab5 --- /dev/null +++ b/examples/prompts/auto-completion/multi-column-autocompletion-with-meta.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python +""" +Autocompletion example that shows meta-information alongside the completions. +""" +from prompt_toolkit.completion import WordCompleter +from prompt_toolkit.shortcuts import CompleteStyle, prompt + +animal_completer = WordCompleter( + [ + "alligator", + "ant", + "ape", + "bat", + "bear", + "beaver", + "bee", + "bison", + "butterfly", + "cat", + "chicken", + "crocodile", + "dinosaur", + "dog", + "dolphin", + "dove", + "duck", + "eagle", + "elephant", + ], + meta_dict={ + "alligator": "An alligator is a crocodilian in the genus Alligator of the family Alligatoridae.", + "ant": "Ants are eusocial insects of the family Formicidae", + "ape": "Apes (Hominoidea) are a branch of Old World tailless anthropoid catarrhine primates ", + "bat": "Bats are mammals of the order Chiroptera", + }, + ignore_case=True, +) + + +def main(): + text = prompt( + "Give some animals: ", + completer=animal_completer, + complete_style=CompleteStyle.MULTI_COLUMN, + ) + print("You said: %s" % text) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/auto-completion/multi-column-autocompletion.py b/examples/prompts/auto-completion/multi-column-autocompletion.py new file mode 100755 index 0000000..7fcfc52 --- /dev/null +++ b/examples/prompts/auto-completion/multi-column-autocompletion.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python +""" +Similar to the autocompletion example. But display all the completions in multiple columns. +""" +from prompt_toolkit.completion import WordCompleter +from prompt_toolkit.shortcuts import CompleteStyle, prompt + +animal_completer = WordCompleter( + [ + "alligator", + "ant", + "ape", + "bat", + "bear", + "beaver", + "bee", + "bison", + "butterfly", + "cat", + "chicken", + "crocodile", + "dinosaur", + "dog", + "dolphin", + "dove", + "duck", + "eagle", + "elephant", + "fish", + "goat", + "gorilla", + "kangaroo", + "leopard", + "lion", + "mouse", + "rabbit", + "rat", + "snake", + "spider", + "turkey", + "turtle", + ], + ignore_case=True, +) + + +def main(): + text = prompt( + "Give some animals: ", + completer=animal_completer, + complete_style=CompleteStyle.MULTI_COLUMN, + ) + print("You said: %s" % text) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/auto-completion/nested-autocompletion.py b/examples/prompts/auto-completion/nested-autocompletion.py new file mode 100755 index 0000000..cd85b8c --- /dev/null +++ b/examples/prompts/auto-completion/nested-autocompletion.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +""" +Example of nested autocompletion. +""" +from prompt_toolkit import prompt +from prompt_toolkit.completion import NestedCompleter + +completer = NestedCompleter.from_nested_dict( + { + "show": {"version": None, "clock": None, "ip": {"interface": {"brief": None}}}, + "exit": None, + } +) + + +def main(): + text = prompt("Type a command: ", completer=completer) + print("You said: %s" % text) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/auto-completion/slow-completions.py b/examples/prompts/auto-completion/slow-completions.py new file mode 100755 index 0000000..cce9d59 --- /dev/null +++ b/examples/prompts/auto-completion/slow-completions.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python +""" +An example of how to deal with slow auto completion code. + +- Running the completions in a thread is possible by wrapping the + `Completer` object in a `ThreadedCompleter`. This makes sure that the + ``get_completions`` generator is executed in a background thread. + + For the `prompt` shortcut, we don't have to wrap the completer ourselves. + Passing `complete_in_thread=True` is sufficient. + +- We also set a `loading` boolean in the completer function to keep track of + when the completer is running, and display this in the toolbar. +""" +import time + +from prompt_toolkit.completion import Completer, Completion +from prompt_toolkit.shortcuts import CompleteStyle, prompt + +WORDS = [ + "alligator", + "ant", + "ape", + "bat", + "bear", + "beaver", + "bee", + "bison", + "butterfly", + "cat", + "chicken", + "crocodile", + "dinosaur", + "dog", + "dolphin", + "dove", + "duck", + "eagle", + "elephant", + "fish", + "goat", + "gorilla", + "kangaroo", + "leopard", + "lion", + "mouse", + "rabbit", + "rat", + "snake", + "spider", + "turkey", + "turtle", +] + + +class SlowCompleter(Completer): + """ + This is a completer that's very slow. + """ + + def __init__(self): + self.loading = 0 + + def get_completions(self, document, complete_event): + # Keep count of how many completion generators are running. + self.loading += 1 + word_before_cursor = document.get_word_before_cursor() + + try: + for word in WORDS: + if word.startswith(word_before_cursor): + time.sleep(0.2) # Simulate slowness. + yield Completion(word, -len(word_before_cursor)) + + finally: + # We use try/finally because this generator can be closed if the + # input text changes before all completions are generated. + self.loading -= 1 + + +def main(): + # We wrap it in a ThreadedCompleter, to make sure it runs in a different + # thread. That way, we don't block the UI while running the completions. + slow_completer = SlowCompleter() + + # Add a bottom toolbar that display when completions are loading. + def bottom_toolbar(): + return " Loading completions... " if slow_completer.loading > 0 else "" + + # Display prompt. + text = prompt( + "Give some animals: ", + completer=slow_completer, + complete_in_thread=True, + complete_while_typing=True, + bottom_toolbar=bottom_toolbar, + complete_style=CompleteStyle.MULTI_COLUMN, + ) + print("You said: %s" % text) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/auto-suggestion.py b/examples/prompts/auto-suggestion.py new file mode 100755 index 0000000..6660777 --- /dev/null +++ b/examples/prompts/auto-suggestion.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python +""" +Simple example of a CLI that demonstrates fish-style auto suggestion. + +When you type some input, it will match the input against the history. If One +entry of the history starts with the given input, then it will show the +remaining part as a suggestion. Pressing the right arrow will insert this +suggestion. +""" +from prompt_toolkit import PromptSession +from prompt_toolkit.auto_suggest import AutoSuggestFromHistory +from prompt_toolkit.history import InMemoryHistory + + +def main(): + # Create some history first. (Easy for testing.) + history = InMemoryHistory() + history.append_string("import os") + history.append_string('print("hello")') + history.append_string('print("world")') + history.append_string("import path") + + # Print help. + print("This CLI has fish-style auto-suggestion enable.") + print('Type for instance "pri", then you\'ll see a suggestion.') + print("Press the right arrow to insert the suggestion.") + print("Press Control-C to retry. Control-D to exit.") + print() + + session = PromptSession( + history=history, + auto_suggest=AutoSuggestFromHistory(), + enable_history_search=True, + ) + + while True: + try: + text = session.prompt("Say something: ") + except KeyboardInterrupt: + pass # Ctrl-C pressed. Try again. + else: + break + + print("You said: %s" % text) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/autocorrection.py b/examples/prompts/autocorrection.py new file mode 100755 index 0000000..6378132 --- /dev/null +++ b/examples/prompts/autocorrection.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python +""" +Example of implementing auto correction while typing. + +The word "impotr" will be corrected when the user types a space afterwards. +""" +from prompt_toolkit import prompt +from prompt_toolkit.key_binding import KeyBindings + +# Database of words to be replaced by typing. +corrections = { + "impotr": "import", + "wolrd": "world", +} + + +def main(): + # We start with a `KeyBindings` for our extra key bindings. + bindings = KeyBindings() + + # We add a custom key binding to space. + @bindings.add(" ") + def _(event): + """ + When space is pressed, we check the word before the cursor, and + autocorrect that. + """ + b = event.app.current_buffer + w = b.document.get_word_before_cursor() + + if w is not None: + if w in corrections: + b.delete_before_cursor(count=len(w)) + b.insert_text(corrections[w]) + + b.insert_text(" ") + + # Read input. + text = prompt("Say something: ", key_bindings=bindings) + print("You said: %s" % text) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/bottom-toolbar.py b/examples/prompts/bottom-toolbar.py new file mode 100755 index 0000000..4980e5b --- /dev/null +++ b/examples/prompts/bottom-toolbar.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python +""" +A few examples of displaying a bottom toolbar. + +The ``prompt`` function takes a ``bottom_toolbar`` attribute. +This can be any kind of formatted text (plain text, HTML or ANSI), or +it can be a callable that takes an App and returns an of these. + +The bottom toolbar will always receive the style 'bottom-toolbar', and the text +inside will get 'bottom-toolbar.text'. These can be used to change the default +style. +""" +import time + +from prompt_toolkit import prompt +from prompt_toolkit.formatted_text import ANSI, HTML +from prompt_toolkit.styles import Style + + +def main(): + # Example 1: fixed text. + text = prompt("Say something: ", bottom_toolbar="This is a toolbar") + print("You said: %s" % text) + + # Example 2: fixed text from a callable: + def get_toolbar(): + return "Bottom toolbar: time=%r" % time.time() + + text = prompt("Say something: ", bottom_toolbar=get_toolbar, refresh_interval=0.5) + print("You said: %s" % text) + + # Example 3: Using HTML: + text = prompt( + "Say something: ", + bottom_toolbar=HTML( + '(html) <b>This</b> <u>is</u> a <style bg="ansired">toolbar</style>' + ), + ) + print("You said: %s" % text) + + # Example 4: Using ANSI: + text = prompt( + "Say something: ", + bottom_toolbar=ANSI( + "(ansi): \x1b[1mThis\x1b[0m \x1b[4mis\x1b[0m a \x1b[91mtoolbar" + ), + ) + print("You said: %s" % text) + + # Example 5: styling differently. + style = Style.from_dict( + { + "bottom-toolbar": "#aaaa00 bg:#ff0000", + "bottom-toolbar.text": "#aaaa44 bg:#aa4444", + } + ) + + text = prompt("Say something: ", bottom_toolbar="This is a toolbar", style=style) + print("You said: %s" % text) + + # Example 6: Using a list of tokens. + def get_bottom_toolbar(): + return [ + ("", " "), + ("bg:#ff0000 fg:#000000", "This"), + ("", " is a "), + ("bg:#ff0000 fg:#000000", "toolbar"), + ("", ". "), + ] + + text = prompt("Say something: ", bottom_toolbar=get_bottom_toolbar) + print("You said: %s" % text) + + # Example 7: multiline fixed text. + text = prompt("Say something: ", bottom_toolbar="This is\na multiline toolbar") + print("You said: %s" % text) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/clock-input.py b/examples/prompts/clock-input.py new file mode 100755 index 0000000..e43abd8 --- /dev/null +++ b/examples/prompts/clock-input.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python +""" +Example of a 'dynamic' prompt. On that shows the current time in the prompt. +""" +import datetime + +from prompt_toolkit.shortcuts import prompt + + +def get_prompt(): + "Tokens to be shown before the prompt." + now = datetime.datetime.now() + return [ + ("bg:#008800 #ffffff", f"{now.hour}:{now.minute}:{now.second}"), + ("bg:cornsilk fg:maroon", " Enter something: "), + ] + + +def main(): + result = prompt(get_prompt, refresh_interval=0.5) + print("You said: %s" % result) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/colored-prompt.py b/examples/prompts/colored-prompt.py new file mode 100755 index 0000000..1e63e29 --- /dev/null +++ b/examples/prompts/colored-prompt.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python +""" +Example of a colored prompt. +""" +from prompt_toolkit import prompt +from prompt_toolkit.formatted_text import ANSI, HTML +from prompt_toolkit.styles import Style + +style = Style.from_dict( + { + # Default style. + "": "#ff0066", + # Prompt. + "username": "#884444 italic", + "at": "#00aa00", + "colon": "#00aa00", + "pound": "#00aa00", + "host": "#000088 bg:#aaaaff", + "path": "#884444 underline", + # Make a selection reverse/underlined. + # (Use Control-Space to select.) + "selected-text": "reverse underline", + } +) + + +def example_1(): + """ + Style and list of (style, text) tuples. + """ + # Not that we can combine class names and inline styles. + prompt_fragments = [ + ("class:username", "john"), + ("class:at", "@"), + ("class:host", "localhost"), + ("class:colon", ":"), + ("class:path", "/user/john"), + ("bg:#00aa00 #ffffff", "#"), + ("", " "), + ] + + answer = prompt(prompt_fragments, style=style) + print("You said: %s" % answer) + + +def example_2(): + """ + Using HTML for the formatting. + """ + answer = prompt( + HTML( + "<username>john</username><at>@</at>" + "<host>localhost</host>" + "<colon>:</colon>" + "<path>/user/john</path>" + '<style bg="#00aa00" fg="#ffffff">#</style> ' + ), + style=style, + ) + print("You said: %s" % answer) + + +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" + "# " + ) + ) + print("You said: %s" % answer) + + +if __name__ == "__main__": + example_1() + example_2() + example_3() diff --git a/examples/prompts/confirmation-prompt.py b/examples/prompts/confirmation-prompt.py new file mode 100755 index 0000000..bd52b9e --- /dev/null +++ b/examples/prompts/confirmation-prompt.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python +""" +Example of a confirmation prompt. +""" +from prompt_toolkit.shortcuts import confirm + +if __name__ == "__main__": + answer = confirm("Should we do that?") + print("You said: %s" % answer) diff --git a/examples/prompts/cursor-shapes.py b/examples/prompts/cursor-shapes.py new file mode 100755 index 0000000..e668243 --- /dev/null +++ b/examples/prompts/cursor-shapes.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python +""" +Example of cursor shape configurations. +""" +from prompt_toolkit import prompt +from prompt_toolkit.cursor_shapes import CursorShape, ModalCursorShapeConfig + +# NOTE: We pass `enable_suspend=True`, so that we can easily see what happens +# to the cursor shapes when the application is suspended. + +prompt("(block): ", cursor=CursorShape.BLOCK, enable_suspend=True) +prompt("(underline): ", cursor=CursorShape.UNDERLINE, enable_suspend=True) +prompt("(beam): ", cursor=CursorShape.BEAM, enable_suspend=True) +prompt( + "(modal - according to vi input mode): ", + cursor=ModalCursorShapeConfig(), + vi_mode=True, + enable_suspend=True, +) diff --git a/examples/prompts/custom-key-binding.py b/examples/prompts/custom-key-binding.py new file mode 100755 index 0000000..32d889e --- /dev/null +++ b/examples/prompts/custom-key-binding.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python +""" +Example of adding a custom key binding to a prompt. +""" +import asyncio + +from prompt_toolkit import prompt +from prompt_toolkit.application import in_terminal, run_in_terminal +from prompt_toolkit.key_binding import KeyBindings + + +def main(): + # We start with a `KeyBindings` of default key bindings. + bindings = KeyBindings() + + # Add our own key binding. + @bindings.add("f4") + def _(event): + """ + When F4 has been pressed. Insert "hello world" as text. + """ + event.app.current_buffer.insert_text("hello world") + + @bindings.add("x", "y") + def _(event): + """ + (Useless, but for demoing.) + Typing 'xy' will insert 'z'. + + Note that when you type for instance 'xa', the insertion of 'x' is + postponed until the 'a' is typed. because we don't know earlier whether + or not a 'y' will follow. However, prompt-toolkit should already give + some visual feedback of the typed character. + """ + event.app.current_buffer.insert_text("z") + + @bindings.add("a", "b", "c") + def _(event): + "Typing 'abc' should insert 'd'." + event.app.current_buffer.insert_text("d") + + @bindings.add("c-t") + def _(event): + """ + Print 'hello world' in the terminal when ControlT is pressed. + + We use ``run_in_terminal``, because that ensures that the prompt is + hidden right before ``print_hello`` gets executed and it's drawn again + after it. (Otherwise this would destroy the output.) + """ + + def print_hello(): + print("hello world") + + run_in_terminal(print_hello) + + @bindings.add("c-k") + async def _(event): + """ + Example of asyncio coroutine as a key binding. + """ + try: + for i in range(5): + async with in_terminal(): + print("hello") + await asyncio.sleep(1) + except asyncio.CancelledError: + print("Prompt terminated before we completed.") + + # Read input. + print('Press F4 to insert "hello world", type "xy" to insert "z":') + text = prompt("> ", key_bindings=bindings) + print("You said: %s" % text) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/custom-lexer.py b/examples/prompts/custom-lexer.py new file mode 100755 index 0000000..c4c9fbe --- /dev/null +++ b/examples/prompts/custom-lexer.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python +""" +An example of a custom lexer that prints the input text in random colors. +""" +from prompt_toolkit.lexers import Lexer +from prompt_toolkit.shortcuts import prompt +from prompt_toolkit.styles.named_colors import NAMED_COLORS + + +class RainbowLexer(Lexer): + def lex_document(self, document): + colors = sorted(NAMED_COLORS, key=NAMED_COLORS.get) + + def get_line(lineno): + return [ + (colors[i % len(colors)], c) + for i, c in enumerate(document.lines[lineno]) + ] + + return get_line + + +def main(): + answer = prompt("Give me some input: ", lexer=RainbowLexer()) + print("You said: %s" % answer) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/custom-vi-operator-and-text-object.py b/examples/prompts/custom-vi-operator-and-text-object.py new file mode 100755 index 0000000..7478afc --- /dev/null +++ b/examples/prompts/custom-vi-operator-and-text-object.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python +""" +Example of adding a custom Vi operator and text object. +(Note that this API is not guaranteed to remain stable.) +""" +from prompt_toolkit import prompt +from prompt_toolkit.enums import EditingMode +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.key_binding.bindings.vi import ( + TextObject, + create_operator_decorator, + create_text_object_decorator, +) + + +def main(): + # We start with a `Registry` of default key bindings. + bindings = KeyBindings() + + # Create the decorators to be used for registering text objects and + # operators in this registry. + operator = create_operator_decorator(bindings) + text_object = create_text_object_decorator(bindings) + + # Create a custom operator. + + @operator("R") + def _(event, text_object): + "Custom operator that reverses text." + buff = event.current_buffer + + # Get relative start/end coordinates. + start, end = text_object.operator_range(buff.document) + start += buff.cursor_position + end += buff.cursor_position + + text = buff.text[start:end] + text = "".join(reversed(text)) + + event.app.current_buffer.text = buff.text[:start] + text + buff.text[end:] + + # Create a text object. + + @text_object("A") + def _(event): + "A custom text object that involves everything." + # Note that a `TextObject` has coordinates, relative to the cursor position. + buff = event.current_buffer + return TextObject( + -buff.document.cursor_position, # The start. + len(buff.text) - buff.document.cursor_position, + ) # The end. + + # Read input. + print('There is a custom text object "A" that applies to everything') + print('and a custom operator "r" that reverses the text object.\n') + + print("Things that are possible:") + print("- Riw - reverse inner word.") + print("- yA - yank everything.") + print("- RA - reverse everything.") + + text = prompt( + "> ", default="hello world", key_bindings=bindings, editing_mode=EditingMode.VI + ) + print("You said: %s" % text) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/enforce-tty-input-output.py b/examples/prompts/enforce-tty-input-output.py new file mode 100755 index 0000000..93b43ee --- /dev/null +++ b/examples/prompts/enforce-tty-input-output.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python +""" +This will display a prompt that will always use the terminal for input and +output, even if sys.stdin/stdout are connected to pipes. + +For testing, run as: + cat /dev/null | python ./enforce-tty-input-output.py > /dev/null +""" +from prompt_toolkit.application import create_app_session_from_tty +from prompt_toolkit.shortcuts import prompt + +with create_app_session_from_tty(): + prompt(">") diff --git a/examples/prompts/fancy-zsh-prompt.py b/examples/prompts/fancy-zsh-prompt.py new file mode 100755 index 0000000..4761c08 --- /dev/null +++ b/examples/prompts/fancy-zsh-prompt.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python +""" +Example of the fancy ZSH prompt that @anki-code was using. + +The theme is coming from the xonsh plugin from the xxh project: +https://github.com/xxh/xxh-plugin-xonsh-theme-bar + +See: +- https://github.com/xonsh/xonsh/issues/3356 +- https://github.com/prompt-toolkit/python-prompt-toolkit/issues/1111 +""" +import datetime + +from prompt_toolkit import prompt +from prompt_toolkit.application import get_app +from prompt_toolkit.formatted_text import ( + HTML, + fragment_list_width, + merge_formatted_text, + to_formatted_text, +) +from prompt_toolkit.styles import Style + +style = Style.from_dict( + { + "username": "#aaaaaa italic", + "path": "#ffffff bold", + "branch": "bg:#666666", + "branch exclamation-mark": "#ff0000", + "env": "bg:#666666", + "left-part": "bg:#444444", + "right-part": "bg:#444444", + "padding": "bg:#444444", + } +) + + +def get_prompt() -> HTML: + """ + Build the prompt dynamically every time its rendered. + """ + left_part = HTML( + "<left-part>" + " <username>root</username> " + " abc " + "<path>~/.oh-my-zsh/themes</path>" + "</left-part>" + ) + right_part = HTML( + "<right-part> " + "<branch> master<exclamation-mark>!</exclamation-mark> </branch> " + " <env> py36 </env> " + " <time>%s</time> " + "</right-part>" + ) % (datetime.datetime.now().isoformat(),) + + used_width = sum( + [ + fragment_list_width(to_formatted_text(left_part)), + fragment_list_width(to_formatted_text(right_part)), + ] + ) + + total_width = get_app().output.get_size().columns + padding_size = total_width - used_width + + padding = HTML("<padding>%s</padding>") % (" " * padding_size,) + + return merge_formatted_text([left_part, padding, right_part, "\n", "# "]) + + +def main() -> None: + while True: + answer = prompt(get_prompt, style=style, refresh_interval=1) + print("You said: %s" % answer) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/finalterm-shell-integration.py b/examples/prompts/finalterm-shell-integration.py new file mode 100755 index 0000000..30c7a7f --- /dev/null +++ b/examples/prompts/finalterm-shell-integration.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python +""" +Mark the start and end of the prompt with Final term (iterm2) escape sequences. +See: https://iterm2.com/finalterm.html +""" +import sys + +from prompt_toolkit import prompt +from prompt_toolkit.formatted_text import ANSI + +BEFORE_PROMPT = "\033]133;A\a" +AFTER_PROMPT = "\033]133;B\a" +BEFORE_OUTPUT = "\033]133;C\a" +AFTER_OUTPUT = ( + "\033]133;D;{command_status}\a" # command_status is the command status, 0-255 +) + + +def get_prompt_text(): + # Generate the text fragments for the prompt. + # Important: use the `ZeroWidthEscape` fragment only if you are sure that + # writing this as raw text to the output will not introduce any + # cursor movements. + return [ + ("[ZeroWidthEscape]", BEFORE_PROMPT), + ("", "Say something: # "), + ("[ZeroWidthEscape]", AFTER_PROMPT), + ] + + +if __name__ == "__main__": + # Option 1: Using a `get_prompt_text` function: + answer = prompt(get_prompt_text) + + # Option 2: Using ANSI escape sequences. + before = "\001" + BEFORE_PROMPT + "\002" + after = "\001" + AFTER_PROMPT + "\002" + answer = prompt(ANSI(f"{before}Say something: # {after}")) + + # Output. + sys.stdout.write(BEFORE_OUTPUT) + print("You said: %s" % answer) + sys.stdout.write(AFTER_OUTPUT.format(command_status=0)) diff --git a/examples/prompts/get-input-vi-mode.py b/examples/prompts/get-input-vi-mode.py new file mode 100755 index 0000000..566ffd5 --- /dev/null +++ b/examples/prompts/get-input-vi-mode.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python +from prompt_toolkit import prompt + +if __name__ == "__main__": + print("You have Vi keybindings here. Press [Esc] to go to navigation mode.") + answer = prompt("Give me some input: ", multiline=False, vi_mode=True) + print("You said: %s" % answer) diff --git a/examples/prompts/get-input-with-default.py b/examples/prompts/get-input-with-default.py new file mode 100755 index 0000000..67446d5 --- /dev/null +++ b/examples/prompts/get-input-with-default.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python +""" +Example of a call to `prompt` with a default value. +The input is pre-filled, but the user can still edit the default. +""" +import getpass + +from prompt_toolkit import prompt + +if __name__ == "__main__": + answer = prompt("What is your name: ", default="%s" % getpass.getuser()) + print("You said: %s" % answer) diff --git a/examples/prompts/get-input.py b/examples/prompts/get-input.py new file mode 100755 index 0000000..5529bbb --- /dev/null +++ b/examples/prompts/get-input.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python +""" +The most simple prompt example. +""" +from prompt_toolkit import prompt + +if __name__ == "__main__": + answer = prompt("Give me some input: ") + print("You said: %s" % answer) diff --git a/examples/prompts/get-multiline-input.py b/examples/prompts/get-multiline-input.py new file mode 100755 index 0000000..eda35be --- /dev/null +++ b/examples/prompts/get-multiline-input.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python +from prompt_toolkit import prompt +from prompt_toolkit.formatted_text import HTML + + +def prompt_continuation(width, line_number, wrap_count): + """ + The continuation: display line numbers and '->' before soft wraps. + + Notice that we can return any kind of formatted text from here. + + The prompt continuation doesn't have to be the same width as the prompt + which is displayed before the first line, but in this example we choose to + align them. The `width` input that we receive here represents the width of + the prompt. + """ + if wrap_count > 0: + return " " * (width - 3) + "-> " + else: + text = ("- %i - " % (line_number + 1)).rjust(width) + return HTML("<strong>%s</strong>") % text + + +if __name__ == "__main__": + print("Press [Meta+Enter] or [Esc] followed by [Enter] to accept input.") + answer = prompt( + "Multiline input: ", multiline=True, prompt_continuation=prompt_continuation + ) + print("You said: %s" % answer) diff --git a/examples/prompts/get-password-with-toggle-display-shortcut.py b/examples/prompts/get-password-with-toggle-display-shortcut.py new file mode 100755 index 0000000..b89cb41 --- /dev/null +++ b/examples/prompts/get-password-with-toggle-display-shortcut.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python +""" +get_password function that displays asterisks instead of the actual characters. +With the addition of a ControlT shortcut to hide/show the input. +""" +from prompt_toolkit import prompt +from prompt_toolkit.filters import Condition +from prompt_toolkit.key_binding import KeyBindings + + +def main(): + hidden = [True] # Nonlocal + bindings = KeyBindings() + + @bindings.add("c-t") + def _(event): + "When ControlT has been pressed, toggle visibility." + hidden[0] = not hidden[0] + + print("Type Control-T to toggle password visible.") + password = prompt( + "Password: ", is_password=Condition(lambda: hidden[0]), key_bindings=bindings + ) + print("You said: %s" % password) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/get-password.py b/examples/prompts/get-password.py new file mode 100755 index 0000000..1c9517c --- /dev/null +++ b/examples/prompts/get-password.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python +from prompt_toolkit import prompt + +if __name__ == "__main__": + password = prompt("Password: ", is_password=True) + print("You said: %s" % password) diff --git a/examples/prompts/history/persistent-history.py b/examples/prompts/history/persistent-history.py new file mode 100755 index 0000000..2bdb758 --- /dev/null +++ b/examples/prompts/history/persistent-history.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python +""" +Simple example of a CLI that keeps a persistent history of all the entered +strings in a file. When you run this script for a second time, pressing +arrow-up will go back in history. +""" +from prompt_toolkit import PromptSession +from prompt_toolkit.history import FileHistory + + +def main(): + our_history = FileHistory(".example-history-file") + + # The history needs to be passed to the `PromptSession`. It can't be passed + # to the `prompt` call because only one history can be used during a + # session. + session = PromptSession(history=our_history) + + while True: + text = session.prompt("Say something: ") + print("You said: %s" % text) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/history/slow-history.py b/examples/prompts/history/slow-history.py new file mode 100755 index 0000000..5b6a7a2 --- /dev/null +++ b/examples/prompts/history/slow-history.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python +""" +Simple example of a custom, very slow history, that is loaded asynchronously. + +By wrapping it in `ThreadedHistory`, the history will load in the background +without blocking any user interaction. +""" +import time + +from prompt_toolkit import PromptSession +from prompt_toolkit.history import History, ThreadedHistory + + +class SlowHistory(History): + """ + Example class that loads the history very slowly... + """ + + def load_history_strings(self): + for i in range(1000): + time.sleep(1) # Emulate slowness. + yield f"item-{i}" + + def store_string(self, string): + pass # Don't store strings. + + +def main(): + print( + "Asynchronous loading of history. Notice that the up-arrow will work " + "for as far as the completions are loaded.\n" + "Even when the input is accepted, loading will continue in the " + "background and when the next prompt is displayed.\n" + ) + our_history = ThreadedHistory(SlowHistory()) + + # The history needs to be passed to the `PromptSession`. It can't be passed + # to the `prompt` call because only one history can be used during a + # session. + session = PromptSession(history=our_history) + + while True: + text = session.prompt("Say something: ") + print("You said: %s" % text) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/html-input.py b/examples/prompts/html-input.py new file mode 100755 index 0000000..4c51737 --- /dev/null +++ b/examples/prompts/html-input.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python +""" +Simple example of a syntax-highlighted HTML input line. +(This requires Pygments to be installed.) +""" +from pygments.lexers.html import HtmlLexer + +from prompt_toolkit import prompt +from prompt_toolkit.lexers import PygmentsLexer + + +def main(): + text = prompt("Enter HTML: ", lexer=PygmentsLexer(HtmlLexer)) + print("You said: %s" % text) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/input-validation.py b/examples/prompts/input-validation.py new file mode 100755 index 0000000..d8bd3ee --- /dev/null +++ b/examples/prompts/input-validation.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +""" +Simple example of input validation. +""" +from prompt_toolkit import prompt +from prompt_toolkit.validation import Validator + + +def is_valid_email(text): + return "@" in text + + +validator = Validator.from_callable( + is_valid_email, + error_message="Not a valid e-mail address (Does not contain an @).", + move_cursor_to_end=True, +) + + +def main(): + # Validate when pressing ENTER. + text = prompt( + "Enter e-mail address: ", validator=validator, validate_while_typing=False + ) + print("You said: %s" % text) + + # While typing + text = prompt( + "Enter e-mail address: ", validator=validator, validate_while_typing=True + ) + print("You said: %s" % text) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/inputhook.py b/examples/prompts/inputhook.py new file mode 100755 index 0000000..7cbfe18 --- /dev/null +++ b/examples/prompts/inputhook.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python +""" +An example that demonstrates how inputhooks can be used in prompt-toolkit. + +An inputhook is a callback that an eventloop calls when it's idle. For +instance, readline calls `PyOS_InputHook`. This allows us to do other work in +the same thread, while waiting for input. Important however is that we give the +control back to prompt-toolkit when some input is ready to be processed. + +There are two ways to know when input is ready. One way is to poll +`InputHookContext.input_is_ready()`. Another way is to check for +`InputHookContext.fileno()` to be ready. In this example we do the latter. +""" +import gobject +import gtk +from pygments.lexers.python import PythonLexer + +from prompt_toolkit.lexers import PygmentsLexer +from prompt_toolkit.patch_stdout import patch_stdout +from prompt_toolkit.shortcuts import PromptSession + + +def hello_world_window(): + """ + Create a GTK window with one 'Hello world' button. + """ + # Create a new window. + window = gtk.Window(gtk.WINDOW_TOPLEVEL) + window.set_border_width(50) + + # Create a new button with the label "Hello World". + button = gtk.Button("Hello World") + window.add(button) + + # Clicking the button prints some text. + def clicked(data): + print("Button clicked!") + + button.connect("clicked", clicked) + + # Display the window. + button.show() + window.show() + + +def inputhook(context): + """ + When the eventloop of prompt-toolkit is idle, call this inputhook. + + This will run the GTK main loop until the file descriptor + `context.fileno()` becomes ready. + + :param context: An `InputHookContext` instance. + """ + + def _main_quit(*a, **kw): + gtk.main_quit() + return False + + gobject.io_add_watch(context.fileno(), gobject.IO_IN, _main_quit) + gtk.main() + + +def main(): + # Create user interface. + hello_world_window() + + # Enable threading in GTK. (Otherwise, GTK will keep the GIL.) + gtk.gdk.threads_init() + + # Read input from the command line, using an event loop with this hook. + # We use `patch_stdout`, because clicking the button will print something; + # and that should print nicely 'above' the input line. + with patch_stdout(): + session = PromptSession( + "Python >>> ", inputhook=inputhook, lexer=PygmentsLexer(PythonLexer) + ) + result = session.prompt() + print("You said: %s" % result) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/mouse-support.py b/examples/prompts/mouse-support.py new file mode 100755 index 0000000..1e4ee76 --- /dev/null +++ b/examples/prompts/mouse-support.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +from prompt_toolkit import prompt + +if __name__ == "__main__": + print( + "This is multiline input. press [Meta+Enter] or [Esc] followed by [Enter] to accept input." + ) + print("You can click with the mouse in order to select text.") + answer = prompt("Multiline input: ", multiline=True, mouse_support=True) + print("You said: %s" % answer) diff --git a/examples/prompts/multiline-prompt.py b/examples/prompts/multiline-prompt.py new file mode 100755 index 0000000..d6a7698 --- /dev/null +++ b/examples/prompts/multiline-prompt.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python +""" +Demonstration of how the input can be indented. +""" +from prompt_toolkit import prompt + +if __name__ == "__main__": + answer = prompt( + "Give me some input: (ESCAPE followed by ENTER to accept)\n > ", multiline=True + ) + print("You said: %s" % answer) diff --git a/examples/prompts/no-wrapping.py b/examples/prompts/no-wrapping.py new file mode 100755 index 0000000..371486e --- /dev/null +++ b/examples/prompts/no-wrapping.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python +from prompt_toolkit import prompt + +if __name__ == "__main__": + answer = prompt("Give me some input: ", wrap_lines=False, multiline=True) + print("You said: %s" % answer) diff --git a/examples/prompts/operate-and-get-next.py b/examples/prompts/operate-and-get-next.py new file mode 100755 index 0000000..6ea4d79 --- /dev/null +++ b/examples/prompts/operate-and-get-next.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python +""" +Demo of "operate-and-get-next". + +(Actually, this creates one prompt application, and keeps running the same app +over and over again. -- For now, this is the only way to get this working.) +""" +from prompt_toolkit.shortcuts import PromptSession + + +def main(): + session = PromptSession("prompt> ") + while True: + session.prompt() + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/patch-stdout.py b/examples/prompts/patch-stdout.py new file mode 100755 index 0000000..1c83524 --- /dev/null +++ b/examples/prompts/patch-stdout.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +""" +An example that demonstrates how `patch_stdout` works. + +This makes sure that output from other threads doesn't disturb the rendering of +the prompt, but instead is printed nicely above the prompt. +""" +import threading +import time + +from prompt_toolkit import prompt +from prompt_toolkit.patch_stdout import patch_stdout + + +def main(): + # Print a counter every second in another thread. + running = True + + def thread(): + i = 0 + while running: + i += 1 + print("i=%i" % i) + time.sleep(1) + + t = threading.Thread(target=thread) + t.daemon = True + t.start() + + # Now read the input. The print statements of the other thread + # should not disturb anything. + with patch_stdout(): + result = prompt("Say something: ") + print("You said: %s" % result) + + # Stop thread. + running = False + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/placeholder-text.py b/examples/prompts/placeholder-text.py new file mode 100755 index 0000000..35e1c6c --- /dev/null +++ b/examples/prompts/placeholder-text.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python +""" +Example of a placeholder that's displayed as long as no input is given. +""" +from prompt_toolkit import prompt +from prompt_toolkit.formatted_text import HTML + +if __name__ == "__main__": + answer = prompt( + "Give me some input: ", + placeholder=HTML('<style color="#888888">(please type something)</style>'), + ) + print("You said: %s" % answer) diff --git a/examples/prompts/regular-language.py b/examples/prompts/regular-language.py new file mode 100755 index 0000000..cbe7256 --- /dev/null +++ b/examples/prompts/regular-language.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python +""" +This is an example of "prompt_toolkit.contrib.regular_languages" which +implements a little calculator. + +Type for instance:: + + > add 4 4 + > sub 4 4 + > sin 3.14 + +This example shows how you can define the grammar of a regular language and how +to use variables in this grammar with completers and tokens attached. +""" +import math + +from prompt_toolkit import prompt +from prompt_toolkit.completion import WordCompleter +from prompt_toolkit.contrib.regular_languages.compiler import compile +from prompt_toolkit.contrib.regular_languages.completion import GrammarCompleter +from prompt_toolkit.contrib.regular_languages.lexer import GrammarLexer +from prompt_toolkit.lexers import SimpleLexer +from prompt_toolkit.styles import Style + +operators1 = ["add", "sub", "div", "mul"] +operators2 = ["cos", "sin"] + + +def create_grammar(): + return compile( + r""" + (\s* (?P<operator1>[a-z]+) \s+ (?P<var1>[0-9.]+) \s+ (?P<var2>[0-9.]+) \s*) | + (\s* (?P<operator2>[a-z]+) \s+ (?P<var1>[0-9.]+) \s*) + """ + ) + + +example_style = Style.from_dict( + { + "operator": "#33aa33 bold", + "number": "#ff0000 bold", + "trailing-input": "bg:#662222 #ffffff", + } +) + + +if __name__ == "__main__": + g = create_grammar() + + lexer = GrammarLexer( + g, + lexers={ + "operator1": SimpleLexer("class:operator"), + "operator2": SimpleLexer("class:operator"), + "var1": SimpleLexer("class:number"), + "var2": SimpleLexer("class:number"), + }, + ) + + completer = GrammarCompleter( + g, + { + "operator1": WordCompleter(operators1), + "operator2": WordCompleter(operators2), + }, + ) + + try: + # REPL loop. + while True: + # Read input and parse the result. + text = prompt( + "Calculate: ", lexer=lexer, completer=completer, style=example_style + ) + m = g.match(text) + if m: + vars = m.variables() + else: + print("Invalid command\n") + continue + + print(vars) + if vars.get("operator1") or vars.get("operator2"): + try: + var1 = float(vars.get("var1", 0)) + var2 = float(vars.get("var2", 0)) + except ValueError: + print("Invalid command (2)\n") + continue + + # Turn the operator string into a function. + operator = { + "add": (lambda a, b: a + b), + "sub": (lambda a, b: a - b), + "mul": (lambda a, b: a * b), + "div": (lambda a, b: a / b), + "sin": (lambda a, b: math.sin(a)), + "cos": (lambda a, b: math.cos(a)), + }[vars.get("operator1") or vars.get("operator2")] + + # Execute and print the result. + print("Result: %s\n" % (operator(var1, var2))) + + elif vars.get("operator2"): + print("Operator 2") + + except EOFError: + pass diff --git a/examples/prompts/rprompt.py b/examples/prompts/rprompt.py new file mode 100755 index 0000000..f7656b7 --- /dev/null +++ b/examples/prompts/rprompt.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python +""" +Example of a right prompt. This is an additional prompt that is displayed on +the right side of the terminal. It will be hidden automatically when the input +is long enough to cover the right side of the terminal. + +This is similar to RPROMPT is Zsh. +""" +from prompt_toolkit import prompt +from prompt_toolkit.formatted_text import ANSI, HTML +from prompt_toolkit.styles import Style + +example_style = Style.from_dict( + { + # The 'rprompt' gets by default the 'rprompt' class. We can use this + # for the styling. + "rprompt": "bg:#ff0066 #ffffff", + } +) + + +def get_rprompt_text(): + return [ + ("", " "), + ("underline", "<rprompt>"), + ("", " "), + ] + + +def main(): + # Option 1: pass a string to 'rprompt': + answer = prompt("> ", rprompt=" <rprompt> ", style=example_style) + print("You said: %s" % answer) + + # Option 2: pass HTML: + answer = prompt("> ", rprompt=HTML(" <u><rprompt></u> "), style=example_style) + print("You said: %s" % answer) + + # Option 3: pass ANSI: + answer = prompt( + "> ", rprompt=ANSI(" \x1b[4m<rprompt>\x1b[0m "), style=example_style + ) + print("You said: %s" % answer) + + # Option 4: Pass a callable. (This callable can either return plain text, + # an HTML object, an ANSI object or a list of (style, text) + # tuples. + answer = prompt("> ", rprompt=get_rprompt_text, style=example_style) + print("You said: %s" % answer) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/swap-light-and-dark-colors.py b/examples/prompts/swap-light-and-dark-colors.py new file mode 100755 index 0000000..e602449 --- /dev/null +++ b/examples/prompts/swap-light-and-dark-colors.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python +""" +Demonstration of swapping light/dark colors in prompt_toolkit using the +`swap_light_and_dark_colors` parameter. + +Notice that this doesn't swap foreground and background like "reverse" does. It +turns light green into dark green and the other way around. Foreground and +background are independent of each other. +""" +from pygments.lexers.html import HtmlLexer + +from prompt_toolkit import prompt +from prompt_toolkit.completion import WordCompleter +from prompt_toolkit.filters import Condition +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.lexers import PygmentsLexer + +html_completer = WordCompleter( + [ + "<body>", + "<div>", + "<head>", + "<html>", + "<img>", + "<li>", + "<link>", + "<ol>", + "<p>", + "<span>", + "<table>", + "<td>", + "<th>", + "<tr>", + "<ul>", + ], + ignore_case=True, +) + + +def main(): + swapped = [False] # Nonlocal + bindings = KeyBindings() + + @bindings.add("c-t") + def _(event): + "When ControlT has been pressed, toggle light/dark colors." + swapped[0] = not swapped[0] + + def bottom_toolbar(): + if swapped[0]: + on = "on=true" + else: + on = "on=false" + + return ( + HTML( + 'Press <style bg="#222222" fg="#ff8888">[control-t]</style> ' + "to swap between dark/light colors. " + '<style bg="ansiblack" fg="ansiwhite">[%s]</style>' + ) + % on + ) + + text = prompt( + HTML('<style fg="#aaaaaa">Give some animals</style>: '), + completer=html_completer, + complete_while_typing=True, + bottom_toolbar=bottom_toolbar, + key_bindings=bindings, + lexer=PygmentsLexer(HtmlLexer), + swap_light_and_dark_colors=Condition(lambda: swapped[0]), + ) + print("You said: %s" % text) + + +if __name__ == "__main__": + main() diff --git a/examples/prompts/switch-between-vi-emacs.py b/examples/prompts/switch-between-vi-emacs.py new file mode 100755 index 0000000..249c7ef --- /dev/null +++ b/examples/prompts/switch-between-vi-emacs.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python +""" +Example that displays how to switch between Emacs and Vi input mode. + +""" +from prompt_toolkit import prompt +from prompt_toolkit.application.current import get_app +from prompt_toolkit.enums import EditingMode +from prompt_toolkit.key_binding import KeyBindings + + +def run(): + # Create a `KeyBindings` that contains the default key bindings. + bindings = KeyBindings() + + # Add an additional key binding for toggling this flag. + @bindings.add("f4") + def _(event): + "Toggle between Emacs and Vi mode." + if event.app.editing_mode == EditingMode.VI: + event.app.editing_mode = EditingMode.EMACS + else: + event.app.editing_mode = EditingMode.VI + + def bottom_toolbar(): + "Display the current input mode." + if get_app().editing_mode == EditingMode.VI: + return " [F4] Vi " + else: + return " [F4] Emacs " + + prompt("> ", key_bindings=bindings, bottom_toolbar=bottom_toolbar) + + +if __name__ == "__main__": + run() diff --git a/examples/prompts/system-clipboard-integration.py b/examples/prompts/system-clipboard-integration.py new file mode 100755 index 0000000..f605921 --- /dev/null +++ b/examples/prompts/system-clipboard-integration.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python +""" +Demonstration of a custom clipboard class. +This requires the 'pyperclip' library to be installed. +""" +from prompt_toolkit import prompt +from prompt_toolkit.clipboard.pyperclip import PyperclipClipboard + +if __name__ == "__main__": + print("Emacs shortcuts:") + print(" Press Control-Y to paste from the system clipboard.") + print(" Press Control-Space or Control-@ to enter selection mode.") + print(" Press Control-W to cut to clipboard.") + print("") + + answer = prompt("Give me some input: ", clipboard=PyperclipClipboard()) + print("You said: %s" % answer) diff --git a/examples/prompts/system-prompt.py b/examples/prompts/system-prompt.py new file mode 100755 index 0000000..47aa2a5 --- /dev/null +++ b/examples/prompts/system-prompt.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python +from prompt_toolkit import prompt + +if __name__ == "__main__": + # System prompt. + print( + "(1/3) If you press meta-! or esc-! at the following prompt, you can enter system commands." + ) + answer = prompt("Give me some input: ", enable_system_prompt=True) + print("You said: %s" % answer) + + # Enable suspend. + print("(2/3) If you press Control-Z, the application will suspend.") + answer = prompt("Give me some input: ", enable_suspend=True) + print("You said: %s" % answer) + + # Enable open_in_editor + print("(3/3) If you press Control-X Control-E, the prompt will open in $EDITOR.") + answer = prompt("Give me some input: ", enable_open_in_editor=True) + print("You said: %s" % answer) diff --git a/examples/prompts/terminal-title.py b/examples/prompts/terminal-title.py new file mode 100755 index 0000000..14f9459 --- /dev/null +++ b/examples/prompts/terminal-title.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +from prompt_toolkit import prompt +from prompt_toolkit.shortcuts import set_title + +if __name__ == "__main__": + set_title("This is the terminal title") + answer = prompt("Give me some input: ") + set_title("") + + print("You said: %s" % answer) diff --git a/examples/prompts/up-arrow-partial-string-matching.py b/examples/prompts/up-arrow-partial-string-matching.py new file mode 100755 index 0000000..742a12e --- /dev/null +++ b/examples/prompts/up-arrow-partial-string-matching.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +""" +Simple example of a CLI that demonstrates up-arrow partial string matching. + +When you type some input, it's possible to use the up arrow to filter the +history on the items starting with the given input text. +""" +from prompt_toolkit import PromptSession +from prompt_toolkit.history import InMemoryHistory + + +def main(): + # Create some history first. (Easy for testing.) + history = InMemoryHistory() + history.append_string("import os") + history.append_string('print("hello")') + history.append_string('print("world")') + history.append_string("import path") + + # Print help. + print("This CLI has up-arrow partial string matching enabled.") + print('Type for instance "pri" followed by up-arrow and you') + print('get the last items starting with "pri".') + print("Press Control-C to retry. Control-D to exit.") + print() + + session = PromptSession(history=history, enable_history_search=True) + + while True: + try: + text = session.prompt("Say something: ") + except KeyboardInterrupt: + pass # Ctrl-C pressed. Try again. + else: + break + + print("You said: %s" % text) + + +if __name__ == "__main__": + main() diff --git a/examples/ssh/asyncssh-server.py b/examples/ssh/asyncssh-server.py new file mode 100755 index 0000000..27d0dd2 --- /dev/null +++ b/examples/ssh/asyncssh-server.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python +""" +Example of running a prompt_toolkit application in an asyncssh server. +""" +import asyncio +import logging + +import asyncssh +from pygments.lexers.html import HtmlLexer + +from prompt_toolkit.completion import WordCompleter +from prompt_toolkit.contrib.ssh import PromptToolkitSSHServer, PromptToolkitSSHSession +from prompt_toolkit.lexers import PygmentsLexer +from prompt_toolkit.shortcuts import ProgressBar, print_formatted_text +from prompt_toolkit.shortcuts.dialogs import input_dialog, yes_no_dialog +from prompt_toolkit.shortcuts.prompt import PromptSession + +animal_completer = WordCompleter( + [ + "alligator", + "ant", + "ape", + "bat", + "bear", + "beaver", + "bee", + "bison", + "butterfly", + "cat", + "chicken", + "crocodile", + "dinosaur", + "dog", + "dolphin", + "dove", + "duck", + "eagle", + "elephant", + "fish", + "goat", + "gorilla", + "kangaroo", + "leopard", + "lion", + "mouse", + "rabbit", + "rat", + "snake", + "spider", + "turkey", + "turtle", + ], + ignore_case=True, +) + + +async def interact(ssh_session: PromptToolkitSSHSession) -> None: + """ + The application interaction. + + This will run automatically in a prompt_toolkit AppSession, which means + that any prompt_toolkit application (dialogs, prompts, etc...) will use the + SSH channel for input and output. + """ + prompt_session = PromptSession() + + # Alias 'print_formatted_text', so that 'print' calls go to the SSH client. + print = print_formatted_text + + print("We will be running a few prompt_toolkit applications through this ") + print("SSH connection.\n") + + # Simple progress bar. + with ProgressBar() as pb: + for i in pb(range(50)): + await asyncio.sleep(0.1) + + # Normal prompt. + text = await prompt_session.prompt_async("(normal prompt) Type something: ") + print("You typed", text) + + # Prompt with auto completion. + text = await prompt_session.prompt_async( + "(autocompletion) Type an animal: ", completer=animal_completer + ) + print("You typed", text) + + # prompt with syntax highlighting. + text = await prompt_session.prompt_async( + "(HTML syntax highlighting) Type something: ", lexer=PygmentsLexer(HtmlLexer) + ) + print("You typed", text) + + # Show yes/no dialog. + await prompt_session.prompt_async("Showing yes/no dialog... [ENTER]") + await yes_no_dialog("Yes/no dialog", "Running over asyncssh").run_async() + + # Show input dialog + await prompt_session.prompt_async("Showing input dialog... [ENTER]") + await input_dialog("Input dialog", "Running over asyncssh").run_async() + + +async def main(port=8222): + # Set up logging. + logging.basicConfig() + logging.getLogger().setLevel(logging.DEBUG) + + await asyncssh.create_server( + lambda: PromptToolkitSSHServer(interact), + "", + port, + server_host_keys=["/etc/ssh/ssh_host_ecdsa_key"], + ) + + # Run forever. + await asyncio.Future() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/telnet/chat-app.py b/examples/telnet/chat-app.py new file mode 100755 index 0000000..2e3508d --- /dev/null +++ b/examples/telnet/chat-app.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python +""" +A simple chat application over telnet. +Everyone that connects is asked for his name, and then people can chat with +each other. +""" +import logging +import random +from asyncio import Future, run + +from prompt_toolkit.contrib.telnet.server import TelnetServer +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.shortcuts import PromptSession, clear + +# Set up logging +logging.basicConfig() +logging.getLogger().setLevel(logging.INFO) + +# List of connections. +_connections = [] +_connection_to_color = {} + + +COLORS = [ + "ansired", + "ansigreen", + "ansiyellow", + "ansiblue", + "ansifuchsia", + "ansiturquoise", + "ansilightgray", + "ansidarkgray", + "ansidarkred", + "ansidarkgreen", + "ansibrown", + "ansidarkblue", + "ansipurple", + "ansiteal", +] + + +async def interact(connection): + write = connection.send + prompt_session = PromptSession() + + # When a client is connected, erase the screen from the client and say + # Hello. + clear() + write("Welcome to our chat application!\n") + write("All connected clients will receive what you say.\n") + + name = await prompt_session.prompt_async(message="Type your name: ") + + # Random color. + color = random.choice(COLORS) + _connection_to_color[connection] = color + + # Send 'connected' message. + _send_to_everyone(connection, name, "(connected)", color) + + # Prompt. + prompt_msg = HTML('<reverse fg="{}">[{}]</reverse> > ').format(color, name) + + _connections.append(connection) + try: + # Set Application. + while True: + try: + result = await prompt_session.prompt_async(message=prompt_msg) + _send_to_everyone(connection, name, result, color) + except KeyboardInterrupt: + pass + except EOFError: + _send_to_everyone(connection, name, "(leaving)", color) + finally: + _connections.remove(connection) + + +def _send_to_everyone(sender_connection, name, message, color): + """ + Send a message to all the clients. + """ + for c in _connections: + if c != sender_connection: + c.send_above_prompt( + [ + ("fg:" + color, "[%s]" % name), + ("", " "), + ("fg:" + color, "%s\n" % message), + ] + ) + + +async def main(): + server = TelnetServer(interact=interact, port=2323) + server.start() + + # Run forever. + await Future() + + +if __name__ == "__main__": + run(main()) diff --git a/examples/telnet/dialog.py b/examples/telnet/dialog.py new file mode 100755 index 0000000..c674a9d --- /dev/null +++ b/examples/telnet/dialog.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python +""" +Example of a telnet application that displays a dialog window. +""" +import logging +from asyncio import Future, run + +from prompt_toolkit.contrib.telnet.server import TelnetServer +from prompt_toolkit.shortcuts.dialogs import yes_no_dialog + +# Set up logging +logging.basicConfig() +logging.getLogger().setLevel(logging.INFO) + + +async def interact(connection): + result = await yes_no_dialog( + title="Yes/no dialog demo", text="Press yes or no" + ).run_async() + + connection.send(f"You said: {result}\n") + connection.send("Bye.\n") + + +async def main(): + server = TelnetServer(interact=interact, port=2323) + server.start() + + # Run forever. + await Future() + + +if __name__ == "__main__": + run(main()) diff --git a/examples/telnet/hello-world.py b/examples/telnet/hello-world.py new file mode 100755 index 0000000..c19c60c --- /dev/null +++ b/examples/telnet/hello-world.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python +""" +A simple Telnet application that asks for input and responds. + +The interaction function is a prompt_toolkit coroutine. +Also see the `hello-world-asyncio.py` example which uses an asyncio coroutine. +That is probably the preferred way if you only need Python 3 support. +""" +import logging +from asyncio import run + +from prompt_toolkit.contrib.telnet.server import TelnetServer +from prompt_toolkit.shortcuts import PromptSession, clear + +# Set up logging +logging.basicConfig() +logging.getLogger().setLevel(logging.INFO) + + +async def interact(connection): + clear() + connection.send("Welcome!\n") + + # Ask for input. + session = PromptSession() + result = await session.prompt_async(message="Say something: ") + + # Send output. + connection.send(f"You said: {result}\n") + connection.send("Bye.\n") + + +async def main(): + server = TelnetServer(interact=interact, port=2323) + await server.run() + + +if __name__ == "__main__": + run(main()) diff --git a/examples/telnet/toolbar.py b/examples/telnet/toolbar.py new file mode 100755 index 0000000..d6ae886 --- /dev/null +++ b/examples/telnet/toolbar.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python +""" +Example of a telnet application that displays a bottom toolbar and completions +in the prompt. +""" +import logging +from asyncio import run + +from prompt_toolkit.completion import WordCompleter +from prompt_toolkit.contrib.telnet.server import TelnetServer +from prompt_toolkit.shortcuts import PromptSession + +# Set up logging +logging.basicConfig() +logging.getLogger().setLevel(logging.INFO) + + +async def interact(connection): + # When a client is connected, erase the screen from the client and say + # Hello. + connection.send("Welcome!\n") + + # Display prompt with bottom toolbar. + animal_completer = WordCompleter(["alligator", "ant"]) + + def get_toolbar(): + return "Bottom toolbar..." + + session = PromptSession() + result = await session.prompt_async( + "Say something: ", bottom_toolbar=get_toolbar, completer=animal_completer + ) + + connection.send(f"You said: {result}\n") + connection.send("Bye.\n") + + +async def main(): + server = TelnetServer(interact=interact, port=2323) + await server.run() + + +if __name__ == "__main__": + run(main()) diff --git a/examples/tutorial/README.md b/examples/tutorial/README.md new file mode 100755 index 0000000..3aa5f70 --- /dev/null +++ b/examples/tutorial/README.md @@ -0,0 +1 @@ +See http://python-prompt-toolkit.readthedocs.io/en/stable/pages/tutorials/repl.html diff --git a/examples/tutorial/sqlite-cli.py b/examples/tutorial/sqlite-cli.py new file mode 100755 index 0000000..ea3e2c8 --- /dev/null +++ b/examples/tutorial/sqlite-cli.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python +import sqlite3 +import sys + +from pygments.lexers.sql import SqlLexer + +from prompt_toolkit import PromptSession +from prompt_toolkit.completion import WordCompleter +from prompt_toolkit.lexers import PygmentsLexer +from prompt_toolkit.styles import Style + +sql_completer = WordCompleter( + [ + "abort", + "action", + "add", + "after", + "all", + "alter", + "analyze", + "and", + "as", + "asc", + "attach", + "autoincrement", + "before", + "begin", + "between", + "by", + "cascade", + "case", + "cast", + "check", + "collate", + "column", + "commit", + "conflict", + "constraint", + "create", + "cross", + "current_date", + "current_time", + "current_timestamp", + "database", + "default", + "deferrable", + "deferred", + "delete", + "desc", + "detach", + "distinct", + "drop", + "each", + "else", + "end", + "escape", + "except", + "exclusive", + "exists", + "explain", + "fail", + "for", + "foreign", + "from", + "full", + "glob", + "group", + "having", + "if", + "ignore", + "immediate", + "in", + "index", + "indexed", + "initially", + "inner", + "insert", + "instead", + "intersect", + "into", + "is", + "isnull", + "join", + "key", + "left", + "like", + "limit", + "match", + "natural", + "no", + "not", + "notnull", + "null", + "of", + "offset", + "on", + "or", + "order", + "outer", + "plan", + "pragma", + "primary", + "query", + "raise", + "recursive", + "references", + "regexp", + "reindex", + "release", + "rename", + "replace", + "restrict", + "right", + "rollback", + "row", + "savepoint", + "select", + "set", + "table", + "temp", + "temporary", + "then", + "to", + "transaction", + "trigger", + "union", + "unique", + "update", + "using", + "vacuum", + "values", + "view", + "virtual", + "when", + "where", + "with", + "without", + ], + ignore_case=True, +) + +style = Style.from_dict( + { + "completion-menu.completion": "bg:#008888 #ffffff", + "completion-menu.completion.current": "bg:#00aaaa #000000", + "scrollbar.background": "bg:#88aaaa", + "scrollbar.button": "bg:#222222", + } +) + + +def main(database): + connection = sqlite3.connect(database) + session = PromptSession( + lexer=PygmentsLexer(SqlLexer), completer=sql_completer, style=style + ) + + while True: + try: + text = session.prompt("> ") + except KeyboardInterrupt: + continue # Control-C pressed. Try again. + except EOFError: + break # Control-D pressed. + + with connection: + try: + messages = connection.execute(text) + except Exception as e: + print(repr(e)) + else: + for message in messages: + print(message) + + print("GoodBye!") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + db = ":memory:" + else: + db = sys.argv[1] + + main(db) diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..be28fce --- /dev/null +++ b/mypy.ini @@ -0,0 +1,18 @@ +[mypy] +# --strict. +check_untyped_defs = True +disallow_any_generics = True +disallow_incomplete_defs = True +disallow_subclassing_any = True +disallow_untyped_calls = True +disallow_untyped_decorators = True +disallow_untyped_defs = True +ignore_missing_imports = True +no_implicit_optional = True +no_implicit_reexport = True +strict_equality = True +strict_optional = True +warn_redundant_casts = True +warn_return_any = True +warn_unused_configs = True +warn_unused_ignores = True diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7b5a835 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,66 @@ +[tool.ruff] +target-version = "py37" +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "C", # flake8-comprehensions + "T", # Print. + "I", # isort + # "B", # flake8-bugbear + "UP", # pyupgrade + "RUF100", # unused-noqa + "Q", # quotes +] +ignore = [ + "E501", # Line too long, handled by black + "C901", # Too complex + "E731", # Assign lambda. + "E402", # Module level import not at the top. + "E741", # Ambiguous variable name. +] + + +[tool.ruff.per-file-ignores] +"examples/*" = ["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. +"src/prompt_toolkit/shortcuts/progress_bar/base.py" = ["T201"] # Print allowed. +"tools/*" = ["T201"] # Print allowed. +"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. + + +[tool.ruff.isort] +known-first-party = ["prompt_toolkit"] +known-third-party = ["pygments", "asyncssh"] + +[tool.typos.default] +extend-ignore-re = [ + "Formicidae", + "Iterm", + "goes", + "iterm", + "prepend", + "prepended", + "prev", + "ret", + "rouble", + "x1b\\[4m", + # Deliberate spelling mistakes in autocorrection.py + "wolrd", + "impotr", + # Lorem ipsum. + "Nam", + "varius", +] + +locale = 'en-us' # US English. + +[tool.typos.files] +extend-exclude = [ + "tests/test_cli.py", + "tests/test_regular_languages.py", +] diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..0b63c97 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,40 @@ +[flake8] +exclude=__init__.py +max_line_length=150 +ignore= + E114, + E116, + E117, + E121, + E122, + E123, + E125, + E126, + E127, + E128, + E131, + E171, + E203, + E211, + E221, + E227, + E231, + E241, + E251, + E301, + E402, + E501, + E701, + E702, + E704, + E731, + E741, + F401, + F403, + F405, + F811, + W503, + W504 + +[pytest:tool] +testpaths=tests diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..ca2170c --- /dev/null +++ b/setup.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python +import os +import re + +from setuptools import find_packages, setup + +with open(os.path.join(os.path.dirname(__file__), "README.rst")) as f: + long_description = f.read() + + +def get_version(package): + """ + Return package version as listed in `__version__` in `__init__.py`. + """ + path = os.path.join(os.path.dirname(__file__), "src", package, "__init__.py") + with open(path, "rb") as f: + init_py = f.read().decode("utf-8") + return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) + + +setup( + name="prompt_toolkit", + author="Jonathan Slenders", + version=get_version("prompt_toolkit"), + url="https://github.com/prompt-toolkit/python-prompt-toolkit", + description="Library for building powerful interactive command lines in Python", + long_description=long_description, + long_description_content_type="text/x-rst", + packages=find_packages(where="src"), + 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", + 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 :: Only", + "Programming Language :: Python", + "Topic :: Software Development", + ], +) diff --git a/src/prompt_toolkit/__init__.py b/src/prompt_toolkit/__init__.py new file mode 100644 index 0000000..82324cb --- /dev/null +++ b/src/prompt_toolkit/__init__.py @@ -0,0 +1,51 @@ +""" +prompt_toolkit +============== + +Author: Jonathan Slenders + +Description: prompt_toolkit is a Library for building powerful interactive + command lines in Python. It can be a replacement for GNU + Readline, but it can be much more than that. + +See the examples directory to learn about the usage. + +Probably, to get started, you might also want to have a look at +`prompt_toolkit.shortcuts.prompt`. +""" +from __future__ import annotations + +import re + +# note: this is a bit more lax than the actual pep 440 to allow for a/b/rc/dev without a number +pep440 = re.compile( + r"^([1-9]\d*!)?(0|[1-9]\d*)(\.(0|[1-9]\d*))*((a|b|rc)(0|[1-9]\d*)?)?(\.post(0|[1-9]\d*))?(\.dev(0|[1-9]\d*)?)?$", + re.UNICODE, +) +from .application import Application +from .formatted_text import ANSI, HTML +from .shortcuts import PromptSession, print_formatted_text, prompt + +# Don't forget to update in `docs/conf.py`! +__version__ = "3.0.43" + +assert pep440.match(__version__) + +# Version tuple. +VERSION = tuple(int(v.rstrip("abrc")) for v in __version__.split(".")[:3]) + + +__all__ = [ + # Application. + "Application", + # Shortcuts. + "prompt", + "PromptSession", + "print_formatted_text", + # Formatted text. + "HTML", + "ANSI", + # Version info. + "__version__", + "VERSION", +] diff --git a/src/prompt_toolkit/application/__init__.py b/src/prompt_toolkit/application/__init__.py new file mode 100644 index 0000000..569d8c0 --- /dev/null +++ b/src/prompt_toolkit/application/__init__.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from .application import Application +from .current import ( + AppSession, + create_app_session, + create_app_session_from_tty, + get_app, + get_app_or_none, + get_app_session, + set_app, +) +from .dummy import DummyApplication +from .run_in_terminal import in_terminal, run_in_terminal + +__all__ = [ + # Application. + "Application", + # Current. + "AppSession", + "get_app_session", + "create_app_session", + "create_app_session_from_tty", + "get_app", + "get_app_or_none", + "set_app", + # Dummy. + "DummyApplication", + # Run_in_terminal + "in_terminal", + "run_in_terminal", +] diff --git a/src/prompt_toolkit/application/application.py b/src/prompt_toolkit/application/application.py new file mode 100644 index 0000000..d463781 --- /dev/null +++ b/src/prompt_toolkit/application/application.py @@ -0,0 +1,1625 @@ +from __future__ import annotations + +import asyncio +import contextvars +import os +import re +import signal +import sys +import threading +import time +from asyncio import ( + AbstractEventLoop, + Future, + Task, + ensure_future, + get_running_loop, + sleep, +) +from contextlib import ExitStack, contextmanager +from subprocess import Popen +from traceback import format_tb +from typing import ( + Any, + Callable, + Coroutine, + Generator, + Generic, + Hashable, + Iterable, + Iterator, + TypeVar, + cast, + overload, +) + +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.cache import SimpleCache +from prompt_toolkit.clipboard import Clipboard, InMemoryClipboard +from prompt_toolkit.cursor_shapes import AnyCursorShapeConfig, to_cursor_shape_config +from prompt_toolkit.data_structures import Size +from prompt_toolkit.enums import EditingMode +from prompt_toolkit.eventloop import ( + InputHook, + get_traceback_from_context, + new_eventloop_with_inputhook, + run_in_executor_with_context, +) +from prompt_toolkit.eventloop.utils import call_soon_threadsafe +from prompt_toolkit.filters import Condition, Filter, FilterOrBool, to_filter +from prompt_toolkit.formatted_text import AnyFormattedText +from prompt_toolkit.input.base import Input +from prompt_toolkit.input.typeahead import get_typeahead, store_typeahead +from prompt_toolkit.key_binding.bindings.page_navigation import ( + load_page_navigation_bindings, +) +from prompt_toolkit.key_binding.defaults import load_key_bindings +from prompt_toolkit.key_binding.emacs_state import EmacsState +from prompt_toolkit.key_binding.key_bindings import ( + Binding, + ConditionalKeyBindings, + GlobalOnlyKeyBindings, + KeyBindings, + KeyBindingsBase, + KeysTuple, + merge_key_bindings, +) +from prompt_toolkit.key_binding.key_processor import KeyPressEvent, KeyProcessor +from prompt_toolkit.key_binding.vi_state import ViState +from prompt_toolkit.keys import Keys +from prompt_toolkit.layout.containers import Container, Window +from prompt_toolkit.layout.controls import BufferControl, UIControl +from prompt_toolkit.layout.dummy import create_dummy_layout +from prompt_toolkit.layout.layout import Layout, walk +from prompt_toolkit.output import ColorDepth, Output +from prompt_toolkit.renderer import Renderer, print_formatted_text +from prompt_toolkit.search import SearchState +from prompt_toolkit.styles import ( + BaseStyle, + DummyStyle, + DummyStyleTransformation, + DynamicStyle, + StyleTransformation, + default_pygments_style, + default_ui_style, + merge_styles, +) +from prompt_toolkit.utils import Event, in_main_thread + +from .current import get_app_session, set_app +from .run_in_terminal import in_terminal, run_in_terminal + +__all__ = [ + "Application", +] + + +E = KeyPressEvent +_AppResult = TypeVar("_AppResult") +ApplicationEventHandler = Callable[["Application[_AppResult]"], None] + +_SIGWINCH = getattr(signal, "SIGWINCH", None) +_SIGTSTP = getattr(signal, "SIGTSTP", None) + + +class Application(Generic[_AppResult]): + """ + The main Application class! + This glues everything together. + + :param layout: A :class:`~prompt_toolkit.layout.Layout` instance. + :param key_bindings: + :class:`~prompt_toolkit.key_binding.KeyBindingsBase` instance for + the key bindings. + :param clipboard: :class:`~prompt_toolkit.clipboard.Clipboard` to use. + :param full_screen: When True, run the application on the alternate screen buffer. + :param color_depth: Any :class:`~.ColorDepth` value, a callable that + returns a :class:`~.ColorDepth` or `None` for default. + :param erase_when_done: (bool) Clear the application output when it finishes. + :param reverse_vi_search_direction: Normally, in Vi mode, a '/' searches + forward and a '?' searches backward. In Readline mode, this is usually + reversed. + :param min_redraw_interval: Number of seconds to wait between redraws. Use + this for applications where `invalidate` is called a lot. This could cause + a lot of terminal output, which some terminals are not able to process. + + `None` means that every `invalidate` will be scheduled right away + (which is usually fine). + + When one `invalidate` is called, but a scheduled redraw of a previous + `invalidate` call has not been executed yet, nothing will happen in any + case. + + :param max_render_postpone_time: When there is high CPU (a lot of other + scheduled calls), postpone the rendering max x seconds. '0' means: + don't postpone. '.5' means: try to draw at least twice a second. + + :param refresh_interval: Automatically invalidate the UI every so many + seconds. When `None` (the default), only invalidate when `invalidate` + has been called. + + :param terminal_size_polling_interval: Poll the terminal size every so many + seconds. Useful if the applications runs in a thread other then then + main thread where SIGWINCH can't be handled, or on Windows. + + Filters: + + :param mouse_support: (:class:`~prompt_toolkit.filters.Filter` or + boolean). When True, enable mouse support. + :param paste_mode: :class:`~prompt_toolkit.filters.Filter` or boolean. + :param editing_mode: :class:`~prompt_toolkit.enums.EditingMode`. + + :param enable_page_navigation_bindings: When `True`, enable the page + navigation key bindings. These include both Emacs and Vi bindings like + page-up, page-down and so on to scroll through pages. Mostly useful for + creating an editor or other full screen applications. Probably, you + don't want this for the implementation of a REPL. By default, this is + enabled if `full_screen` is set. + + Callbacks (all of these should accept an + :class:`~prompt_toolkit.application.Application` object as input.) + + :param on_reset: Called during reset. + :param on_invalidate: Called when the UI has been invalidated. + :param before_render: Called right before rendering. + :param after_render: Called right after rendering. + + I/O: + (Note that the preferred way to change the input/output is by creating an + `AppSession` with the required input/output objects. If you need multiple + applications running at the same time, you have to create a separate + `AppSession` using a `with create_app_session():` block. + + :param input: :class:`~prompt_toolkit.input.Input` instance. + :param output: :class:`~prompt_toolkit.output.Output` instance. (Probably + Vt100_Output or Win32Output.) + + Usage: + + app = Application(...) + app.run() + + # Or + await app.run_async() + """ + + def __init__( + self, + layout: Layout | None = None, + style: BaseStyle | None = None, + include_default_pygments_style: FilterOrBool = True, + style_transformation: StyleTransformation | None = None, + key_bindings: KeyBindingsBase | None = None, + clipboard: Clipboard | None = None, + full_screen: bool = False, + color_depth: (ColorDepth | Callable[[], ColorDepth | None] | None) = None, + mouse_support: FilterOrBool = False, + enable_page_navigation_bindings: None + | (FilterOrBool) = None, # Can be None, True or False. + paste_mode: FilterOrBool = False, + editing_mode: EditingMode = EditingMode.EMACS, + erase_when_done: bool = False, + reverse_vi_search_direction: FilterOrBool = False, + min_redraw_interval: float | int | None = None, + max_render_postpone_time: float | int | None = 0.01, + refresh_interval: float | None = None, + terminal_size_polling_interval: float | None = 0.5, + cursor: AnyCursorShapeConfig = None, + on_reset: ApplicationEventHandler[_AppResult] | None = None, + on_invalidate: ApplicationEventHandler[_AppResult] | None = None, + before_render: ApplicationEventHandler[_AppResult] | None = None, + after_render: ApplicationEventHandler[_AppResult] | None = None, + # I/O. + input: Input | None = None, + output: Output | None = None, + ) -> None: + # If `enable_page_navigation_bindings` is not specified, enable it in + # case of full screen applications only. This can be overridden by the user. + if enable_page_navigation_bindings is None: + enable_page_navigation_bindings = Condition(lambda: self.full_screen) + + paste_mode = to_filter(paste_mode) + mouse_support = to_filter(mouse_support) + reverse_vi_search_direction = to_filter(reverse_vi_search_direction) + enable_page_navigation_bindings = to_filter(enable_page_navigation_bindings) + include_default_pygments_style = to_filter(include_default_pygments_style) + + if layout is None: + layout = create_dummy_layout() + + if style_transformation is None: + style_transformation = DummyStyleTransformation() + + self.style = style + self.style_transformation = style_transformation + + # Key bindings. + self.key_bindings = key_bindings + self._default_bindings = load_key_bindings() + self._page_navigation_bindings = load_page_navigation_bindings() + + self.layout = layout + self.clipboard = clipboard or InMemoryClipboard() + self.full_screen: bool = full_screen + self._color_depth = color_depth + self.mouse_support = mouse_support + + self.paste_mode = paste_mode + self.editing_mode = editing_mode + self.erase_when_done = erase_when_done + self.reverse_vi_search_direction = reverse_vi_search_direction + self.enable_page_navigation_bindings = enable_page_navigation_bindings + self.min_redraw_interval = min_redraw_interval + self.max_render_postpone_time = max_render_postpone_time + self.refresh_interval = refresh_interval + self.terminal_size_polling_interval = terminal_size_polling_interval + + self.cursor = to_cursor_shape_config(cursor) + + # Events. + self.on_invalidate = Event(self, on_invalidate) + self.on_reset = Event(self, on_reset) + self.before_render = Event(self, before_render) + self.after_render = Event(self, after_render) + + # I/O. + session = get_app_session() + self.output = output or session.output + self.input = input or session.input + + # List of 'extra' functions to execute before a Application.run. + self.pre_run_callables: list[Callable[[], None]] = [] + + self._is_running = False + self.future: Future[_AppResult] | None = None + self.loop: AbstractEventLoop | None = None + self._loop_thread: threading.Thread | None = None + self.context: contextvars.Context | None = None + + #: Quoted insert. This flag is set if we go into quoted insert mode. + self.quoted_insert = False + + #: Vi state. (For Vi key bindings.) + self.vi_state = ViState() + self.emacs_state = EmacsState() + + #: When to flush the input (For flushing escape keys.) This is important + #: on terminals that use vt100 input. We can't distinguish the escape + #: key from for instance the left-arrow key, if we don't know what follows + #: after "\x1b". This little timer will consider "\x1b" to be escape if + #: nothing did follow in this time span. + #: This seems to work like the `ttimeoutlen` option in Vim. + self.ttimeoutlen = 0.5 # Seconds. + + #: Like Vim's `timeoutlen` option. This can be `None` or a float. For + #: instance, suppose that we have a key binding AB and a second key + #: binding A. If the uses presses A and then waits, we don't handle + #: this binding yet (unless it was marked 'eager'), because we don't + #: know what will follow. This timeout is the maximum amount of time + #: that we wait until we call the handlers anyway. Pass `None` to + #: disable this timeout. + self.timeoutlen = 1.0 + + #: The `Renderer` instance. + # Make sure that the same stdout is used, when a custom renderer has been passed. + self._merged_style = self._create_merged_style(include_default_pygments_style) + + self.renderer = Renderer( + self._merged_style, + self.output, + full_screen=full_screen, + mouse_support=mouse_support, + cpr_not_supported_callback=self.cpr_not_supported_callback, + ) + + #: Render counter. This one is increased every time the UI is rendered. + #: It can be used as a key for caching certain information during one + #: rendering. + self.render_counter = 0 + + # Invalidate flag. When 'True', a repaint has been scheduled. + self._invalidated = False + self._invalidate_events: list[ + Event[object] + ] = [] # Collection of 'invalidate' Event objects. + self._last_redraw_time = 0.0 # Unix timestamp of last redraw. Used when + # `min_redraw_interval` is given. + + #: The `InputProcessor` instance. + self.key_processor = KeyProcessor(_CombinedRegistry(self)) + + # If `run_in_terminal` was called. This will point to a `Future` what will be + # set at the point when the previous run finishes. + self._running_in_terminal = False + self._running_in_terminal_f: Future[None] | None = None + + # Trigger initialize callback. + self.reset() + + def _create_merged_style(self, include_default_pygments_style: Filter) -> BaseStyle: + """ + Create a `Style` object that merges the default UI style, the default + pygments style, and the custom user style. + """ + dummy_style = DummyStyle() + pygments_style = default_pygments_style() + + @DynamicStyle + def conditional_pygments_style() -> BaseStyle: + if include_default_pygments_style(): + return pygments_style + else: + return dummy_style + + return merge_styles( + [ + default_ui_style(), + conditional_pygments_style, + DynamicStyle(lambda: self.style), + ] + ) + + @property + def color_depth(self) -> ColorDepth: + """ + The active :class:`.ColorDepth`. + + The current value is determined as follows: + + - If a color depth was given explicitly to this application, use that + value. + - Otherwise, fall back to the color depth that is reported by the + :class:`.Output` implementation. If the :class:`.Output` class was + created using `output.defaults.create_output`, then this value is + coming from the $PROMPT_TOOLKIT_COLOR_DEPTH environment variable. + """ + depth = self._color_depth + + if callable(depth): + depth = depth() + + if depth is None: + depth = self.output.get_default_color_depth() + + return depth + + @property + def current_buffer(self) -> Buffer: + """ + The currently focused :class:`~.Buffer`. + + (This returns a dummy :class:`.Buffer` when none of the actual buffers + has the focus. In this case, it's really not practical to check for + `None` values or catch exceptions every time.) + """ + return self.layout.current_buffer or Buffer( + name="dummy-buffer" + ) # Dummy buffer. + + @property + def current_search_state(self) -> SearchState: + """ + Return the current :class:`.SearchState`. (The one for the focused + :class:`.BufferControl`.) + """ + ui_control = self.layout.current_control + if isinstance(ui_control, BufferControl): + return ui_control.search_state + else: + return SearchState() # Dummy search state. (Don't return None!) + + def reset(self) -> None: + """ + Reset everything, for reading the next input. + """ + # Notice that we don't reset the buffers. (This happens just before + # returning, and when we have multiple buffers, we clearly want the + # content in the other buffers to remain unchanged between several + # calls of `run`. (And the same is true for the focus stack.) + + self.exit_style = "" + + self._background_tasks: set[Task[None]] = set() + + self.renderer.reset() + self.key_processor.reset() + self.layout.reset() + self.vi_state.reset() + self.emacs_state.reset() + + # Trigger reset event. + self.on_reset.fire() + + # Make sure that we have a 'focusable' widget focused. + # (The `Layout` class can't determine this.) + layout = self.layout + + if not layout.current_control.is_focusable(): + for w in layout.find_all_windows(): + if w.content.is_focusable(): + layout.current_window = w + break + + def invalidate(self) -> None: + """ + Thread safe way of sending a repaint trigger to the input event loop. + """ + if not self._is_running: + # Don't schedule a redraw if we're not running. + # Otherwise, `get_running_loop()` in `call_soon_threadsafe` can fail. + # See: https://github.com/dbcli/mycli/issues/797 + return + + # `invalidate()` called if we don't have a loop yet (not running?), or + # after the event loop was closed. + if self.loop is None or self.loop.is_closed(): + return + + # Never schedule a second redraw, when a previous one has not yet been + # executed. (This should protect against other threads calling + # 'invalidate' many times, resulting in 100% CPU.) + if self._invalidated: + return + else: + self._invalidated = True + + # Trigger event. + self.loop.call_soon_threadsafe(self.on_invalidate.fire) + + def redraw() -> None: + self._invalidated = False + self._redraw() + + def schedule_redraw() -> None: + call_soon_threadsafe( + redraw, max_postpone_time=self.max_render_postpone_time, loop=self.loop + ) + + if self.min_redraw_interval: + # When a minimum redraw interval is set, wait minimum this amount + # of time between redraws. + diff = time.time() - self._last_redraw_time + if diff < self.min_redraw_interval: + + async def redraw_in_future() -> None: + await sleep(cast(float, self.min_redraw_interval) - diff) + schedule_redraw() + + self.loop.call_soon_threadsafe( + lambda: self.create_background_task(redraw_in_future()) + ) + else: + schedule_redraw() + else: + schedule_redraw() + + @property + def invalidated(self) -> bool: + "True when a redraw operation has been scheduled." + return self._invalidated + + def _redraw(self, render_as_done: bool = False) -> None: + """ + Render the command line again. (Not thread safe!) (From other threads, + or if unsure, use :meth:`.Application.invalidate`.) + + :param render_as_done: make sure to put the cursor after the UI. + """ + + def run_in_context() -> None: + # Only draw when no sub application was started. + if self._is_running and not self._running_in_terminal: + if self.min_redraw_interval: + self._last_redraw_time = time.time() + + # Render + self.render_counter += 1 + self.before_render.fire() + + if render_as_done: + if self.erase_when_done: + self.renderer.erase() + else: + # Draw in 'done' state and reset renderer. + self.renderer.render(self, self.layout, is_done=render_as_done) + else: + self.renderer.render(self, self.layout) + + self.layout.update_parents_relations() + + # Fire render event. + self.after_render.fire() + + self._update_invalidate_events() + + # NOTE: We want to make sure this Application is the active one. The + # invalidate function is often called from a context where this + # application is not the active one. (Like the + # `PromptSession._auto_refresh_context`). + # We copy the context in case the context was already active, to + # prevent RuntimeErrors. (The rendering is not supposed to change + # any context variables.) + if self.context is not None: + self.context.copy().run(run_in_context) + + def _start_auto_refresh_task(self) -> None: + """ + Start a while/true loop in the background for automatic invalidation of + the UI. + """ + if self.refresh_interval is not None and self.refresh_interval != 0: + + async def auto_refresh(refresh_interval: float) -> None: + while True: + await sleep(refresh_interval) + self.invalidate() + + self.create_background_task(auto_refresh(self.refresh_interval)) + + def _update_invalidate_events(self) -> None: + """ + Make sure to attach 'invalidate' handlers to all invalidate events in + the UI. + """ + # Remove all the original event handlers. (Components can be removed + # from the UI.) + for ev in self._invalidate_events: + ev -= self._invalidate_handler + + # Gather all new events. + # (All controls are able to invalidate themselves.) + def gather_events() -> Iterable[Event[object]]: + for c in self.layout.find_all_controls(): + yield from c.get_invalidate_events() + + self._invalidate_events = list(gather_events()) + + for ev in self._invalidate_events: + ev += self._invalidate_handler + + def _invalidate_handler(self, sender: object) -> None: + """ + Handler for invalidate events coming from UIControls. + + (This handles the difference in signature between event handler and + `self.invalidate`. It also needs to be a method -not a nested + function-, so that we can remove it again .) + """ + self.invalidate() + + def _on_resize(self) -> None: + """ + When the window size changes, we erase the current output and request + again the cursor position. When the CPR answer arrives, the output is + drawn again. + """ + # Erase, request position (when cursor is at the start position) + # and redraw again. -- The order is important. + self.renderer.erase(leave_alternate_screen=False) + self._request_absolute_cursor_position() + self._redraw() + + def _pre_run(self, pre_run: Callable[[], None] | None = None) -> None: + """ + Called during `run`. + + `self.future` should be set to the new future at the point where this + is called in order to avoid data races. `pre_run` can be used to set a + `threading.Event` to synchronize with UI termination code, running in + another thread that would call `Application.exit`. (See the progress + bar code for an example.) + """ + if pre_run: + pre_run() + + # Process registered "pre_run_callables" and clear list. + for c in self.pre_run_callables: + c() + del self.pre_run_callables[:] + + async def run_async( + self, + pre_run: Callable[[], None] | None = None, + set_exception_handler: bool = True, + handle_sigint: bool = True, + slow_callback_duration: float = 0.5, + ) -> _AppResult: + """ + Run the prompt_toolkit :class:`~prompt_toolkit.application.Application` + until :meth:`~prompt_toolkit.application.Application.exit` has been + called. Return the value that was passed to + :meth:`~prompt_toolkit.application.Application.exit`. + + This is the main entry point for a prompt_toolkit + :class:`~prompt_toolkit.application.Application` and usually the only + place where the event loop is actually running. + + :param pre_run: Optional callable, which is called right after the + "reset" of the application. + :param set_exception_handler: When set, in case of an exception, go out + of the alternate screen and hide the application, display the + exception, and wait for the user to press ENTER. + :param handle_sigint: Handle SIGINT signal if possible. This will call + the `<sigint>` key binding when a SIGINT is received. (This only + works in the main thread.) + :param slow_callback_duration: Display warnings if code scheduled in + the asyncio event loop takes more time than this. The asyncio + default of `0.1` is sometimes not sufficient on a slow system, + because exceptionally, the drawing of the app, which happens in the + event loop, can take a bit longer from time to time. + """ + assert not self._is_running, "Application is already running." + + if not in_main_thread() or sys.platform == "win32": + # Handling signals in other threads is not supported. + # Also on Windows, `add_signal_handler(signal.SIGINT, ...)` raises + # `NotImplementedError`. + # See: https://github.com/prompt-toolkit/python-prompt-toolkit/issues/1553 + handle_sigint = False + + async def _run_async(f: asyncio.Future[_AppResult]) -> _AppResult: + context = contextvars.copy_context() + self.context = context + + # Counter for cancelling 'flush' timeouts. Every time when a key is + # pressed, we start a 'flush' timer for flushing our escape key. But + # when any subsequent input is received, a new timer is started and + # the current timer will be ignored. + flush_task: asyncio.Task[None] | None = None + + # Reset. + # (`self.future` needs to be set when `pre_run` is called.) + self.reset() + self._pre_run(pre_run) + + # Feed type ahead input first. + self.key_processor.feed_multiple(get_typeahead(self.input)) + self.key_processor.process_keys() + + def read_from_input() -> None: + nonlocal flush_task + + # Ignore when we aren't running anymore. This callback will + # removed from the loop next time. (It could be that it was + # still in the 'tasks' list of the loop.) + # Except: if we need to process incoming CPRs. + if not self._is_running and not self.renderer.waiting_for_cpr: + return + + # Get keys from the input object. + keys = self.input.read_keys() + + # Feed to key processor. + self.key_processor.feed_multiple(keys) + self.key_processor.process_keys() + + # Quit when the input stream was closed. + if self.input.closed: + if not f.done(): + f.set_exception(EOFError) + else: + # Automatically flush keys. + if flush_task: + flush_task.cancel() + flush_task = self.create_background_task(auto_flush_input()) + + def read_from_input_in_context() -> None: + # Ensure that key bindings callbacks are always executed in the + # current context. This is important when key bindings are + # accessing contextvars. (These callbacks are currently being + # called from a different context. Underneath, + # `loop.add_reader` is used to register the stdin FD.) + # (We copy the context to avoid a `RuntimeError` in case the + # context is already active.) + context.copy().run(read_from_input) + + async def auto_flush_input() -> None: + # Flush input after timeout. + # (Used for flushing the enter key.) + # This sleep can be cancelled, in that case we won't flush yet. + await sleep(self.ttimeoutlen) + flush_input() + + def flush_input() -> None: + if not self.is_done: + # Get keys, and feed to key processor. + keys = self.input.flush_keys() + self.key_processor.feed_multiple(keys) + self.key_processor.process_keys() + + if self.input.closed: + f.set_exception(EOFError) + + # Enter raw mode, attach input and attach WINCH event handler. + with self.input.raw_mode(), self.input.attach( + read_from_input_in_context + ), attach_winch_signal_handler(self._on_resize): + # Draw UI. + self._request_absolute_cursor_position() + self._redraw() + self._start_auto_refresh_task() + + self.create_background_task(self._poll_output_size()) + + # Wait for UI to finish. + try: + result = await f + finally: + # In any case, when the application finishes. + # (Successful, or because of an error.) + try: + self._redraw(render_as_done=True) + finally: + # _redraw has a good chance to fail if it calls widgets + # with bad code. Make sure to reset the renderer + # anyway. + self.renderer.reset() + + # Unset `is_running`, this ensures that possibly + # scheduled draws won't paint during the following + # yield. + self._is_running = False + + # Detach event handlers for invalidate events. + # (Important when a UIControl is embedded in multiple + # applications, like ptterm in pymux. An invalidate + # should not trigger a repaint in terminated + # applications.) + for ev in self._invalidate_events: + ev -= self._invalidate_handler + self._invalidate_events = [] + + # Wait for CPR responses. + if self.output.responds_to_cpr: + await self.renderer.wait_for_cpr_responses() + + # Wait for the run-in-terminals to terminate. + previous_run_in_terminal_f = self._running_in_terminal_f + + if previous_run_in_terminal_f: + await previous_run_in_terminal_f + + # Store unprocessed input as typeahead for next time. + store_typeahead(self.input, self.key_processor.empty_queue()) + + return result + + @contextmanager + def set_loop() -> Iterator[AbstractEventLoop]: + loop = get_running_loop() + self.loop = loop + self._loop_thread = threading.current_thread() + + try: + yield loop + finally: + self.loop = None + self._loop_thread = None + + @contextmanager + def set_is_running() -> Iterator[None]: + self._is_running = True + try: + yield + finally: + self._is_running = False + + @contextmanager + def set_handle_sigint(loop: AbstractEventLoop) -> Iterator[None]: + if handle_sigint: + with _restore_sigint_from_ctypes(): + # save sigint handlers (python and os level) + # See: https://github.com/prompt-toolkit/python-prompt-toolkit/issues/1576 + loop.add_signal_handler( + signal.SIGINT, + lambda *_: loop.call_soon_threadsafe( + self.key_processor.send_sigint + ), + ) + try: + yield + finally: + loop.remove_signal_handler(signal.SIGINT) + else: + yield + + @contextmanager + def set_exception_handler_ctx(loop: AbstractEventLoop) -> Iterator[None]: + if set_exception_handler: + previous_exc_handler = loop.get_exception_handler() + loop.set_exception_handler(self._handle_exception) + try: + yield + finally: + loop.set_exception_handler(previous_exc_handler) + + else: + yield + + @contextmanager + def set_callback_duration(loop: AbstractEventLoop) -> Iterator[None]: + # Set slow_callback_duration. + original_slow_callback_duration = loop.slow_callback_duration + loop.slow_callback_duration = slow_callback_duration + try: + yield + finally: + # Reset slow_callback_duration. + loop.slow_callback_duration = original_slow_callback_duration + + @contextmanager + def create_future( + loop: AbstractEventLoop, + ) -> Iterator[asyncio.Future[_AppResult]]: + f = loop.create_future() + self.future = f # XXX: make sure to set this before calling '_redraw'. + + try: + yield f + finally: + # Also remove the Future again. (This brings the + # application back to its initial state, where it also + # doesn't have a Future.) + self.future = None + + with ExitStack() as stack: + stack.enter_context(set_is_running()) + + # Make sure to set `_invalidated` to `False` to begin with, + # otherwise we're not going to paint anything. This can happen if + # this application had run before on a different event loop, and a + # paint was scheduled using `call_soon_threadsafe` with + # `max_postpone_time`. + self._invalidated = False + + loop = stack.enter_context(set_loop()) + + stack.enter_context(set_handle_sigint(loop)) + stack.enter_context(set_exception_handler_ctx(loop)) + stack.enter_context(set_callback_duration(loop)) + stack.enter_context(set_app(self)) + stack.enter_context(self._enable_breakpointhook()) + + f = stack.enter_context(create_future(loop)) + + try: + return await _run_async(f) + finally: + # Wait for the background tasks to be done. This needs to + # go in the finally! If `_run_async` raises + # `KeyboardInterrupt`, we still want to wait for the + # background tasks. + await self.cancel_and_wait_for_background_tasks() + + # The `ExitStack` above is defined in typeshed in a way that it can + # swallow exceptions. Without next line, mypy would think that there's + # a possibility we don't return here. See: + # https://github.com/python/mypy/issues/7726 + assert False, "unreachable" + + def run( + self, + pre_run: Callable[[], None] | None = None, + set_exception_handler: bool = True, + handle_sigint: bool = True, + in_thread: bool = False, + inputhook: InputHook | None = None, + ) -> _AppResult: + """ + A blocking 'run' call that waits until the UI is finished. + + This will run the application in a fresh asyncio event loop. + + :param pre_run: Optional callable, which is called right after the + "reset" of the application. + :param set_exception_handler: When set, in case of an exception, go out + of the alternate screen and hide the application, display the + exception, and wait for the user to press ENTER. + :param in_thread: When true, run the application in a background + thread, and block the current thread until the application + terminates. This is useful if we need to be sure the application + won't use the current event loop (asyncio does not support nested + event loops). A new event loop will be created in this background + thread, and that loop will also be closed when the background + thread terminates. When this is used, it's especially important to + make sure that all asyncio background tasks are managed through + `get_appp().create_background_task()`, so that unfinished tasks are + properly cancelled before the event loop is closed. This is used + for instance in ptpython. + :param handle_sigint: Handle SIGINT signal. Call the key binding for + `Keys.SIGINT`. (This only works in the main thread.) + """ + if in_thread: + result: _AppResult + exception: BaseException | None = None + + def run_in_thread() -> None: + nonlocal result, exception + try: + result = self.run( + pre_run=pre_run, + set_exception_handler=set_exception_handler, + # Signal handling only works in the main thread. + handle_sigint=False, + inputhook=inputhook, + ) + except BaseException as e: + exception = e + + thread = threading.Thread(target=run_in_thread) + thread.start() + thread.join() + + if exception is not None: + raise exception + return result + + coro = self.run_async( + pre_run=pre_run, + set_exception_handler=set_exception_handler, + handle_sigint=handle_sigint, + ) + + def _called_from_ipython() -> bool: + try: + return ( + sys.modules["IPython"].version_info < (8, 18, 0, "") + and "IPython/terminal/interactiveshell.py" + in sys._getframe(3).f_code.co_filename + ) + except BaseException: + return False + + if inputhook is not None: + # Create new event loop with given input hook and run the app. + # In Python 3.12, we can use asyncio.run(loop_factory=...) + # For now, use `run_until_complete()`. + loop = new_eventloop_with_inputhook(inputhook) + result = loop.run_until_complete(coro) + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.close() + return result + + elif _called_from_ipython(): + # workaround to make input hooks work for IPython until + # https://github.com/ipython/ipython/pull/14241 is merged. + # IPython was setting the input hook by installing an event loop + # previously. + try: + # See whether a loop was installed already. If so, use that. + # That's required for the input hooks to work, they are + # installed using `set_event_loop`. + loop = asyncio.get_event_loop() + except RuntimeError: + # No loop installed. Run like usual. + return asyncio.run(coro) + else: + # Use existing loop. + return loop.run_until_complete(coro) + + else: + # No loop installed. Run like usual. + return asyncio.run(coro) + + def _handle_exception( + self, loop: AbstractEventLoop, context: dict[str, Any] + ) -> None: + """ + Handler for event loop exceptions. + This will print the exception, using run_in_terminal. + """ + # For Python 2: we have to get traceback at this point, because + # we're still in the 'except:' block of the event loop where the + # traceback is still available. Moving this code in the + # 'print_exception' coroutine will loose the exception. + tb = get_traceback_from_context(context) + formatted_tb = "".join(format_tb(tb)) + + async def in_term() -> None: + async with in_terminal(): + # Print output. Similar to 'loop.default_exception_handler', + # but don't use logger. (This works better on Python 2.) + print("\nUnhandled exception in event loop:") + print(formatted_tb) + print("Exception {}".format(context.get("exception"))) + + await _do_wait_for_enter("Press ENTER to continue...") + + ensure_future(in_term()) + + @contextmanager + def _enable_breakpointhook(self) -> Generator[None, None, None]: + """ + Install our custom breakpointhook for the duration of this context + manager. (We will only install the hook if no other custom hook was + set.) + """ + if sys.breakpointhook == sys.__breakpointhook__: + sys.breakpointhook = self._breakpointhook + + try: + yield + finally: + sys.breakpointhook = sys.__breakpointhook__ + else: + yield + + def _breakpointhook(self, *a: object, **kw: object) -> None: + """ + Breakpointhook which uses PDB, but ensures that the application is + hidden and input echoing is restored during each debugger dispatch. + + This can be called from any thread. In any case, the application's + event loop will be blocked while the PDB input is displayed. The event + will continue after leaving the debugger. + """ + app = self + # Inline import on purpose. We don't want to import pdb, if not needed. + import pdb + from types import FrameType + + TraceDispatch = Callable[[FrameType, str, Any], Any] + + @contextmanager + def hide_app_from_eventloop_thread() -> Generator[None, None, None]: + """Stop application if `__breakpointhook__` is called from within + the App's event loop.""" + # Hide application. + app.renderer.erase() + + # Detach input and dispatch to debugger. + with app.input.detach(): + with app.input.cooked_mode(): + yield + + # Note: we don't render the application again here, because + # there's a good chance that there's a breakpoint on the next + # line. This paint/erase cycle would move the PDB prompt back + # to the middle of the screen. + + @contextmanager + def hide_app_from_other_thread() -> Generator[None, None, None]: + """Stop application if `__breakpointhook__` is called from a + thread other than the App's event loop.""" + ready = threading.Event() + done = threading.Event() + + async def in_loop() -> None: + # from .run_in_terminal import in_terminal + # async with in_terminal(): + # ready.set() + # await asyncio.get_running_loop().run_in_executor(None, done.wait) + # return + + # Hide application. + app.renderer.erase() + + # Detach input and dispatch to debugger. + with app.input.detach(): + with app.input.cooked_mode(): + ready.set() + # Here we block the App's event loop thread until the + # debugger resumes. We could have used `with + # run_in_terminal.in_terminal():` like the commented + # code above, but it seems to work better if we + # completely stop the main event loop while debugging. + done.wait() + + self.create_background_task(in_loop()) + ready.wait() + try: + yield + finally: + done.set() + + class CustomPdb(pdb.Pdb): + def trace_dispatch( + self, frame: FrameType, event: str, arg: Any + ) -> TraceDispatch: + if app._loop_thread is None: + return super().trace_dispatch(frame, event, arg) + + if app._loop_thread == threading.current_thread(): + with hide_app_from_eventloop_thread(): + return super().trace_dispatch(frame, event, arg) + + with hide_app_from_other_thread(): + return super().trace_dispatch(frame, event, arg) + + frame = sys._getframe().f_back + CustomPdb(stdout=sys.__stdout__).set_trace(frame) + + def create_background_task( + self, coroutine: Coroutine[Any, Any, None] + ) -> asyncio.Task[None]: + """ + Start a background task (coroutine) for the running application. When + the `Application` terminates, unfinished background tasks will be + cancelled. + + Given that we still support Python versions before 3.11, we can't use + task groups (and exception groups), because of that, these background + tasks are not allowed to raise exceptions. If they do, we'll call the + default exception handler from the event loop. + + If at some point, we have Python 3.11 as the minimum supported Python + version, then we can use a `TaskGroup` (with the lifetime of + `Application.run_async()`, and run run the background tasks in there. + + This is not threadsafe. + """ + loop = self.loop or get_running_loop() + task: asyncio.Task[None] = loop.create_task(coroutine) + self._background_tasks.add(task) + + task.add_done_callback(self._on_background_task_done) + return task + + def _on_background_task_done(self, task: asyncio.Task[None]) -> None: + """ + Called when a background task completes. Remove it from + `_background_tasks`, and handle exceptions if any. + """ + self._background_tasks.discard(task) + + if task.cancelled(): + return + + exc = task.exception() + if exc is not None: + get_running_loop().call_exception_handler( + { + "message": f"prompt_toolkit.Application background task {task!r} " + "raised an unexpected exception.", + "exception": exc, + "task": task, + } + ) + + async def cancel_and_wait_for_background_tasks(self) -> None: + """ + Cancel all background tasks, and wait for the cancellation to complete. + If any of the background tasks raised an exception, this will also + propagate the exception. + + (If we had nurseries like Trio, this would be the `__aexit__` of a + nursery.) + """ + for task in self._background_tasks: + task.cancel() + + # Wait until the cancellation of the background tasks completes. + # `asyncio.wait()` does not propagate exceptions raised within any of + # these tasks, which is what we want. Otherwise, we can't distinguish + # between a `CancelledError` raised in this task because it got + # cancelled, and a `CancelledError` raised on this `await` checkpoint, + # because *we* got cancelled during the teardown of the application. + # (If we get cancelled here, then it's important to not suppress the + # `CancelledError`, and have it propagate.) + # NOTE: Currently, if we get cancelled at this point then we can't wait + # for the cancellation to complete (in the future, we should be + # using anyio or Python's 3.11 TaskGroup.) + # Also, if we had exception groups, we could propagate an + # `ExceptionGroup` if something went wrong here. Right now, we + # don't propagate exceptions, but have them printed in + # `_on_background_task_done`. + if len(self._background_tasks) > 0: + await asyncio.wait( + self._background_tasks, timeout=None, return_when=asyncio.ALL_COMPLETED + ) + + async def _poll_output_size(self) -> None: + """ + Coroutine for polling the terminal dimensions. + + Useful for situations where `attach_winch_signal_handler` is not sufficient: + - If we are not running in the main thread. + - On Windows. + """ + size: Size | None = None + interval = self.terminal_size_polling_interval + + if interval is None: + return + + while True: + await asyncio.sleep(interval) + new_size = self.output.get_size() + + if size is not None and new_size != size: + self._on_resize() + size = new_size + + def cpr_not_supported_callback(self) -> None: + """ + Called when we don't receive the cursor position response in time. + """ + if not self.output.responds_to_cpr: + return # We know about this already. + + def in_terminal() -> None: + self.output.write( + "WARNING: your terminal doesn't support cursor position requests (CPR).\r\n" + ) + self.output.flush() + + run_in_terminal(in_terminal) + + @overload + def exit(self) -> None: + "Exit without arguments." + + @overload + def exit(self, *, result: _AppResult, style: str = "") -> None: + "Exit with `_AppResult`." + + @overload + def exit( + self, *, exception: BaseException | type[BaseException], style: str = "" + ) -> None: + "Exit with exception." + + def exit( + self, + result: _AppResult | None = None, + exception: BaseException | type[BaseException] | None = None, + style: str = "", + ) -> None: + """ + Exit application. + + .. note:: + + If `Application.exit` is called before `Application.run()` is + called, then the `Application` won't exit (because the + `Application.future` doesn't correspond to the current run). Use a + `pre_run` hook and an event to synchronize the closing if there's a + chance this can happen. + + :param result: Set this result for the application. + :param exception: Set this exception as the result for an application. For + a prompt, this is often `EOFError` or `KeyboardInterrupt`. + :param style: Apply this style on the whole content when quitting, + often this is 'class:exiting' for a prompt. (Used when + `erase_when_done` is not set.) + """ + assert result is None or exception is None + + if self.future is None: + raise Exception("Application is not running. Application.exit() failed.") + + if self.future.done(): + raise Exception("Return value already set. Application.exit() failed.") + + self.exit_style = style + + if exception is not None: + self.future.set_exception(exception) + else: + self.future.set_result(cast(_AppResult, result)) + + def _request_absolute_cursor_position(self) -> None: + """ + Send CPR request. + """ + # Note: only do this if the input queue is not empty, and a return + # value has not been set. Otherwise, we won't be able to read the + # response anyway. + if not self.key_processor.input_queue and not self.is_done: + self.renderer.request_absolute_cursor_position() + + async def run_system_command( + self, + command: str, + wait_for_enter: bool = True, + display_before_text: AnyFormattedText = "", + wait_text: str = "Press ENTER to continue...", + ) -> None: + """ + Run system command (While hiding the prompt. When finished, all the + output will scroll above the prompt.) + + :param command: Shell command to be executed. + :param wait_for_enter: FWait for the user to press enter, when the + command is finished. + :param display_before_text: If given, text to be displayed before the + command executes. + :return: A `Future` object. + """ + async with in_terminal(): + # Try to use the same input/output file descriptors as the one, + # used to run this application. + try: + input_fd = self.input.fileno() + except AttributeError: + input_fd = sys.stdin.fileno() + try: + output_fd = self.output.fileno() + except AttributeError: + output_fd = sys.stdout.fileno() + + # Run sub process. + def run_command() -> None: + self.print_text(display_before_text) + p = Popen(command, shell=True, stdin=input_fd, stdout=output_fd) + p.wait() + + await run_in_executor_with_context(run_command) + + # Wait for the user to press enter. + if wait_for_enter: + await _do_wait_for_enter(wait_text) + + def suspend_to_background(self, suspend_group: bool = True) -> None: + """ + (Not thread safe -- to be called from inside the key bindings.) + Suspend process. + + :param suspend_group: When true, suspend the whole process group. + (This is the default, and probably what you want.) + """ + # Only suspend when the operating system supports it. + # (Not on Windows.) + if _SIGTSTP is not None: + + def run() -> None: + signal = cast(int, _SIGTSTP) + # Send `SIGTSTP` to own process. + # This will cause it to suspend. + + # Usually we want the whole process group to be suspended. This + # handles the case when input is piped from another process. + if suspend_group: + os.kill(0, signal) + else: + os.kill(os.getpid(), signal) + + run_in_terminal(run) + + def print_text( + self, text: AnyFormattedText, style: BaseStyle | None = None + ) -> None: + """ + Print a list of (style_str, text) tuples to the output. + (When the UI is running, this method has to be called through + `run_in_terminal`, otherwise it will destroy the UI.) + + :param text: List of ``(style_str, text)`` tuples. + :param style: Style class to use. Defaults to the active style in the CLI. + """ + print_formatted_text( + output=self.output, + formatted_text=text, + style=style or self._merged_style, + color_depth=self.color_depth, + style_transformation=self.style_transformation, + ) + + @property + def is_running(self) -> bool: + "`True` when the application is currently active/running." + return self._is_running + + @property + def is_done(self) -> bool: + if self.future: + return self.future.done() + return False + + def get_used_style_strings(self) -> list[str]: + """ + Return a list of used style strings. This is helpful for debugging, and + for writing a new `Style`. + """ + attrs_for_style = self.renderer._attrs_for_style + + if attrs_for_style: + return sorted( + re.sub(r"\s+", " ", style_str).strip() + for style_str in attrs_for_style.keys() + ) + + return [] + + +class _CombinedRegistry(KeyBindingsBase): + """ + The `KeyBindings` of key bindings for a `Application`. + This merges the global key bindings with the one of the current user + control. + """ + + def __init__(self, app: Application[_AppResult]) -> None: + self.app = app + self._cache: SimpleCache[ + tuple[Window, frozenset[UIControl]], KeyBindingsBase + ] = SimpleCache() + + @property + def _version(self) -> Hashable: + """Not needed - this object is not going to be wrapped in another + KeyBindings object.""" + raise NotImplementedError + + @property + def bindings(self) -> list[Binding]: + """Not needed - this object is not going to be wrapped in another + KeyBindings object.""" + raise NotImplementedError + + def _create_key_bindings( + self, current_window: Window, other_controls: list[UIControl] + ) -> KeyBindingsBase: + """ + Create a `KeyBindings` object that merges the `KeyBindings` from the + `UIControl` with all the parent controls and the global key bindings. + """ + key_bindings = [] + collected_containers = set() + + # Collect key bindings from currently focused control and all parent + # controls. Don't include key bindings of container parent controls. + container: Container = current_window + while True: + collected_containers.add(container) + kb = container.get_key_bindings() + if kb is not None: + key_bindings.append(kb) + + if container.is_modal(): + break + + parent = self.app.layout.get_parent(container) + if parent is None: + break + else: + container = parent + + # Include global bindings (starting at the top-model container). + for c in walk(container): + if c not in collected_containers: + kb = c.get_key_bindings() + if kb is not None: + key_bindings.append(GlobalOnlyKeyBindings(kb)) + + # Add App key bindings + if self.app.key_bindings: + key_bindings.append(self.app.key_bindings) + + # Add mouse bindings. + key_bindings.append( + ConditionalKeyBindings( + self.app._page_navigation_bindings, + self.app.enable_page_navigation_bindings, + ) + ) + key_bindings.append(self.app._default_bindings) + + # Reverse this list. The current control's key bindings should come + # last. They need priority. + key_bindings = key_bindings[::-1] + + return merge_key_bindings(key_bindings) + + @property + def _key_bindings(self) -> KeyBindingsBase: + current_window = self.app.layout.current_window + other_controls = list(self.app.layout.find_all_controls()) + key = current_window, frozenset(other_controls) + + return self._cache.get( + key, lambda: self._create_key_bindings(current_window, other_controls) + ) + + def get_bindings_for_keys(self, keys: KeysTuple) -> list[Binding]: + return self._key_bindings.get_bindings_for_keys(keys) + + def get_bindings_starting_with_keys(self, keys: KeysTuple) -> list[Binding]: + return self._key_bindings.get_bindings_starting_with_keys(keys) + + +async def _do_wait_for_enter(wait_text: AnyFormattedText) -> None: + """ + Create a sub application to wait for the enter key press. + This has two advantages over using 'input'/'raw_input': + - This will share the same input/output I/O. + - This doesn't block the event loop. + """ + from prompt_toolkit.shortcuts import PromptSession + + key_bindings = KeyBindings() + + @key_bindings.add("enter") + def _ok(event: E) -> None: + event.app.exit() + + @key_bindings.add(Keys.Any) + def _ignore(event: E) -> None: + "Disallow typing." + pass + + session: PromptSession[None] = PromptSession( + message=wait_text, key_bindings=key_bindings + ) + try: + await session.app.run_async() + except KeyboardInterrupt: + pass # Control-c pressed. Don't propagate this error. + + +@contextmanager +def attach_winch_signal_handler( + handler: Callable[[], None], +) -> Generator[None, None, None]: + """ + Attach the given callback as a WINCH signal handler within the context + manager. Restore the original signal handler when done. + + The `Application.run` method will register SIGWINCH, so that it will + properly repaint when the terminal window resizes. However, using + `run_in_terminal`, we can temporarily send an application to the + background, and run an other app in between, which will then overwrite the + SIGWINCH. This is why it's important to restore the handler when the app + terminates. + """ + # The tricky part here is that signals are registered in the Unix event + # loop with a wakeup fd, but another application could have registered + # signals using signal.signal directly. For now, the implementation is + # hard-coded for the `asyncio.unix_events._UnixSelectorEventLoop`. + + # No WINCH? Then don't do anything. + sigwinch = getattr(signal, "SIGWINCH", None) + if sigwinch is None or not in_main_thread(): + yield + return + + # Keep track of the previous handler. + # (Only UnixSelectorEventloop has `_signal_handlers`.) + loop = get_running_loop() + previous_winch_handler = getattr(loop, "_signal_handlers", {}).get(sigwinch) + + try: + loop.add_signal_handler(sigwinch, handler) + yield + finally: + # Restore the previous signal handler. + loop.remove_signal_handler(sigwinch) + if previous_winch_handler is not None: + loop.add_signal_handler( + sigwinch, + previous_winch_handler._callback, + *previous_winch_handler._args, + ) + + +@contextmanager +def _restore_sigint_from_ctypes() -> Generator[None, None, None]: + # The following functions are part of the stable ABI since python 3.2 + # See: https://docs.python.org/3/c-api/sys.html#c.PyOS_getsig + # Inline import: these are not available on Pypy. + try: + from ctypes import c_int, c_void_p, pythonapi + except ImportError: + # Any of the above imports don't exist? Don't do anything here. + yield + return + + # PyOS_sighandler_t PyOS_getsig(int i) + pythonapi.PyOS_getsig.restype = c_void_p + pythonapi.PyOS_getsig.argtypes = (c_int,) + + # PyOS_sighandler_t PyOS_setsig(int i, PyOS_sighandler_t h) + pythonapi.PyOS_setsig.restype = c_void_p + pythonapi.PyOS_setsig.argtypes = ( + c_int, + c_void_p, + ) + + sigint = signal.getsignal(signal.SIGINT) + sigint_os = pythonapi.PyOS_getsig(signal.SIGINT) + + try: + yield + finally: + signal.signal(signal.SIGINT, sigint) + pythonapi.PyOS_setsig(signal.SIGINT, sigint_os) diff --git a/src/prompt_toolkit/application/current.py b/src/prompt_toolkit/application/current.py new file mode 100644 index 0000000..908141a --- /dev/null +++ b/src/prompt_toolkit/application/current.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from contextlib import contextmanager +from contextvars import ContextVar +from typing import TYPE_CHECKING, Any, Generator + +if TYPE_CHECKING: + from prompt_toolkit.input.base import Input + from prompt_toolkit.output.base import Output + + from .application import Application + +__all__ = [ + "AppSession", + "get_app_session", + "get_app", + "get_app_or_none", + "set_app", + "create_app_session", + "create_app_session_from_tty", +] + + +class AppSession: + """ + An AppSession is an interactive session, usually connected to one terminal. + Within one such session, interaction with many applications can happen, one + after the other. + + The input/output device is not supposed to change during one session. + + Warning: Always use the `create_app_session` function to create an + instance, so that it gets activated correctly. + + :param input: Use this as a default input for all applications + running in this session, unless an input is passed to the `Application` + explicitly. + :param output: Use this as a default output. + """ + + def __init__( + self, input: Input | None = None, output: Output | None = None + ) -> None: + self._input = input + self._output = output + + # The application will be set dynamically by the `set_app` context + # manager. This is called in the application itself. + self.app: Application[Any] | None = None + + def __repr__(self) -> str: + return f"AppSession(app={self.app!r})" + + @property + def input(self) -> Input: + if self._input is None: + from prompt_toolkit.input.defaults import create_input + + self._input = create_input() + return self._input + + @property + def output(self) -> Output: + if self._output is None: + from prompt_toolkit.output.defaults import create_output + + self._output = create_output() + return self._output + + +_current_app_session: ContextVar[AppSession] = ContextVar( + "_current_app_session", default=AppSession() +) + + +def get_app_session() -> AppSession: + return _current_app_session.get() + + +def get_app() -> Application[Any]: + """ + Get the current active (running) Application. + An :class:`.Application` is active during the + :meth:`.Application.run_async` call. + + We assume that there can only be one :class:`.Application` active at the + same time. There is only one terminal window, with only one stdin and + stdout. This makes the code significantly easier than passing around the + :class:`.Application` everywhere. + + If no :class:`.Application` is running, then return by default a + :class:`.DummyApplication`. For practical reasons, we prefer to not raise + an exception. This way, we don't have to check all over the place whether + an actual `Application` was returned. + + (For applications like pymux where we can have more than one `Application`, + we'll use a work-around to handle that.) + """ + session = _current_app_session.get() + if session.app is not None: + return session.app + + from .dummy import DummyApplication + + return DummyApplication() + + +def get_app_or_none() -> Application[Any] | None: + """ + Get the current active (running) Application, or return `None` if no + application is running. + """ + session = _current_app_session.get() + return session.app + + +@contextmanager +def set_app(app: Application[Any]) -> Generator[None, None, None]: + """ + Context manager that sets the given :class:`.Application` active in an + `AppSession`. + + This should only be called by the `Application` itself. + The application will automatically be active while its running. If you want + the application to be active in other threads/coroutines, where that's not + the case, use `contextvars.copy_context()`, or use `Application.context` to + run it in the appropriate context. + """ + session = _current_app_session.get() + + previous_app = session.app + session.app = app + try: + yield + finally: + session.app = previous_app + + +@contextmanager +def create_app_session( + input: Input | None = None, output: Output | None = None +) -> Generator[AppSession, None, None]: + """ + 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. + """ + # If no input/output is specified, fall back to the current input/output, + # whatever that is. + if input is None: + input = get_app_session().input + if output is None: + output = get_app_session().output + + # Create new `AppSession` and activate. + session = AppSession(input=input, output=output) + + token = _current_app_session.set(session) + try: + yield session + finally: + _current_app_session.reset(token) + + +@contextmanager +def create_app_session_from_tty() -> Generator[AppSession, None, None]: + """ + Create `AppSession` that always prefers the TTY input/output. + + Even if `sys.stdin` and `sys.stdout` are connected to input/output pipes, + this will still use the terminal for interaction (because `sys.stderr` is + still connected to the terminal). + + Usage:: + + from prompt_toolkit.shortcuts import prompt + + with create_app_session_from_tty(): + prompt('>') + """ + from prompt_toolkit.input.defaults import create_input + from prompt_toolkit.output.defaults import create_output + + input = create_input(always_prefer_tty=True) + output = create_output(always_prefer_tty=True) + + with create_app_session(input=input, output=output) as app_session: + yield app_session diff --git a/src/prompt_toolkit/application/dummy.py b/src/prompt_toolkit/application/dummy.py new file mode 100644 index 0000000..43819e1 --- /dev/null +++ b/src/prompt_toolkit/application/dummy.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from typing import Callable + +from prompt_toolkit.eventloop import InputHook +from prompt_toolkit.formatted_text import AnyFormattedText +from prompt_toolkit.input import DummyInput +from prompt_toolkit.output import DummyOutput + +from .application import Application + +__all__ = [ + "DummyApplication", +] + + +class DummyApplication(Application[None]): + """ + When no :class:`.Application` is running, + :func:`.get_app` will run an instance of this :class:`.DummyApplication` instead. + """ + + def __init__(self) -> None: + super().__init__(output=DummyOutput(), input=DummyInput()) + + def run( + self, + pre_run: Callable[[], None] | None = None, + set_exception_handler: bool = True, + handle_sigint: bool = True, + in_thread: bool = False, + inputhook: InputHook | None = None, + ) -> None: + raise NotImplementedError("A DummyApplication is not supposed to run.") + + async def run_async( + self, + pre_run: Callable[[], None] | None = None, + set_exception_handler: bool = True, + handle_sigint: bool = True, + slow_callback_duration: float = 0.5, + ) -> None: + raise NotImplementedError("A DummyApplication is not supposed to run.") + + async def run_system_command( + self, + command: str, + wait_for_enter: bool = True, + display_before_text: AnyFormattedText = "", + wait_text: str = "", + ) -> None: + raise NotImplementedError + + def suspend_to_background(self, suspend_group: bool = True) -> None: + raise NotImplementedError diff --git a/src/prompt_toolkit/application/run_in_terminal.py b/src/prompt_toolkit/application/run_in_terminal.py new file mode 100644 index 0000000..1e4da2d --- /dev/null +++ b/src/prompt_toolkit/application/run_in_terminal.py @@ -0,0 +1,113 @@ +""" +Tools for running functions on the terminal above the current application or prompt. +""" +from __future__ import annotations + +from asyncio import Future, ensure_future +from contextlib import asynccontextmanager +from typing import AsyncGenerator, Awaitable, Callable, TypeVar + +from prompt_toolkit.eventloop import run_in_executor_with_context + +from .current import get_app_or_none + +__all__ = [ + "run_in_terminal", + "in_terminal", +] + +_T = TypeVar("_T") + + +def run_in_terminal( + func: Callable[[], _T], render_cli_done: bool = False, in_executor: bool = False +) -> Awaitable[_T]: + """ + Run function on the terminal above the current application or prompt. + + What this does is first hiding the prompt, then running this callable + (which can safely output to the terminal), and then again rendering the + prompt which causes the output of this function to scroll above the + prompt. + + ``func`` is supposed to be a synchronous function. If you need an + asynchronous version of this function, use the ``in_terminal`` context + manager directly. + + :param func: The callable to execute. + :param render_cli_done: When True, render the interface in the + 'Done' state first, then execute the function. If False, + erase the interface first. + :param in_executor: When True, run in executor. (Use this for long + blocking functions, when you don't want to block the event loop.) + + :returns: A `Future`. + """ + + async def run() -> _T: + async with in_terminal(render_cli_done=render_cli_done): + if in_executor: + return await run_in_executor_with_context(func) + else: + return func() + + return ensure_future(run()) + + +@asynccontextmanager +async def in_terminal(render_cli_done: bool = False) -> AsyncGenerator[None, None]: + """ + Asynchronous context manager that suspends the current application and runs + the body in the terminal. + + .. code:: + + async def f(): + async with in_terminal(): + call_some_function() + await call_some_async_function() + """ + app = get_app_or_none() + if app is None or not app._is_running: + yield + return + + # When a previous `run_in_terminal` call was in progress. Wait for that + # to finish, before starting this one. Chain to previous call. + previous_run_in_terminal_f = app._running_in_terminal_f + new_run_in_terminal_f: Future[None] = Future() + app._running_in_terminal_f = new_run_in_terminal_f + + # Wait for the previous `run_in_terminal` to finish. + if previous_run_in_terminal_f is not None: + await previous_run_in_terminal_f + + # Wait for all CPRs to arrive. We don't want to detach the input until + # all cursor position responses have been arrived. Otherwise, the tty + # will echo its input and can show stuff like ^[[39;1R. + if app.output.responds_to_cpr: + await app.renderer.wait_for_cpr_responses() + + # Draw interface in 'done' state, or erase. + if render_cli_done: + app._redraw(render_as_done=True) + else: + app.renderer.erase() + + # Disable rendering. + app._running_in_terminal = True + + # Detach input. + try: + with app.input.detach(): + with app.input.cooked_mode(): + yield + finally: + # Redraw interface again. + try: + app._running_in_terminal = False + app.renderer.reset() + app._request_absolute_cursor_position() + app._redraw() + finally: + new_run_in_terminal_f.set_result(None) diff --git a/src/prompt_toolkit/auto_suggest.py b/src/prompt_toolkit/auto_suggest.py new file mode 100644 index 0000000..98cb4dd --- /dev/null +++ b/src/prompt_toolkit/auto_suggest.py @@ -0,0 +1,176 @@ +""" +`Fish-style <http://fishshell.com/>`_ like auto-suggestion. + +While a user types input in a certain buffer, suggestions are generated +(asynchronously.) Usually, they are displayed after the input. When the cursor +presses the right arrow and the cursor is at the end of the input, the +suggestion will be inserted. + +If you want the auto suggestions to be asynchronous (in a background thread), +because they take too much time, and could potentially block the event loop, +then wrap the :class:`.AutoSuggest` instance into a +:class:`.ThreadedAutoSuggest`. +""" +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from typing import TYPE_CHECKING, Callable + +from prompt_toolkit.eventloop import run_in_executor_with_context + +from .document import Document +from .filters import Filter, to_filter + +if TYPE_CHECKING: + from .buffer import Buffer + +__all__ = [ + "Suggestion", + "AutoSuggest", + "ThreadedAutoSuggest", + "DummyAutoSuggest", + "AutoSuggestFromHistory", + "ConditionalAutoSuggest", + "DynamicAutoSuggest", +] + + +class Suggestion: + """ + Suggestion returned by an auto-suggest algorithm. + + :param text: The suggestion text. + """ + + def __init__(self, text: str) -> None: + self.text = text + + def __repr__(self) -> str: + return "Suggestion(%s)" % self.text + + +class AutoSuggest(metaclass=ABCMeta): + """ + Base class for auto suggestion implementations. + """ + + @abstractmethod + def get_suggestion(self, buffer: Buffer, document: Document) -> Suggestion | None: + """ + Return `None` or a :class:`.Suggestion` instance. + + We receive both :class:`~prompt_toolkit.buffer.Buffer` and + :class:`~prompt_toolkit.document.Document`. The reason is that auto + suggestions are retrieved asynchronously. (Like completions.) The + buffer text could be changed in the meantime, but ``document`` contains + the buffer document like it was at the start of the auto suggestion + call. So, from here, don't access ``buffer.text``, but use + ``document.text`` instead. + + :param buffer: The :class:`~prompt_toolkit.buffer.Buffer` instance. + :param document: The :class:`~prompt_toolkit.document.Document` instance. + """ + + async def get_suggestion_async( + self, buff: Buffer, document: Document + ) -> Suggestion | None: + """ + Return a :class:`.Future` which is set when the suggestions are ready. + This function can be overloaded in order to provide an asynchronous + implementation. + """ + return self.get_suggestion(buff, document) + + +class ThreadedAutoSuggest(AutoSuggest): + """ + Wrapper that runs auto suggestions in a thread. + (Use this to prevent the user interface from becoming unresponsive if the + generation of suggestions takes too much time.) + """ + + def __init__(self, auto_suggest: AutoSuggest) -> None: + self.auto_suggest = auto_suggest + + def get_suggestion(self, buff: Buffer, document: Document) -> Suggestion | None: + return self.auto_suggest.get_suggestion(buff, document) + + async def get_suggestion_async( + self, buff: Buffer, document: Document + ) -> Suggestion | None: + """ + Run the `get_suggestion` function in a thread. + """ + + def run_get_suggestion_thread() -> Suggestion | None: + return self.get_suggestion(buff, document) + + return await run_in_executor_with_context(run_get_suggestion_thread) + + +class DummyAutoSuggest(AutoSuggest): + """ + AutoSuggest class that doesn't return any suggestion. + """ + + def get_suggestion(self, buffer: Buffer, document: Document) -> Suggestion | None: + return None # No suggestion + + +class AutoSuggestFromHistory(AutoSuggest): + """ + Give suggestions based on the lines in the history. + """ + + def get_suggestion(self, buffer: Buffer, document: Document) -> Suggestion | None: + history = buffer.history + + # Consider only the last line for the suggestion. + text = document.text.rsplit("\n", 1)[-1] + + # Only create a suggestion when this is not an empty line. + if text.strip(): + # Find first matching line in history. + for string in reversed(list(history.get_strings())): + for line in reversed(string.splitlines()): + if line.startswith(text): + return Suggestion(line[len(text) :]) + + return None + + +class ConditionalAutoSuggest(AutoSuggest): + """ + Auto suggest that can be turned on and of according to a certain condition. + """ + + def __init__(self, auto_suggest: AutoSuggest, filter: bool | Filter) -> None: + self.auto_suggest = auto_suggest + self.filter = to_filter(filter) + + def get_suggestion(self, buffer: Buffer, document: Document) -> Suggestion | None: + if self.filter(): + return self.auto_suggest.get_suggestion(buffer, document) + + return None + + +class DynamicAutoSuggest(AutoSuggest): + """ + Validator class that can dynamically returns any Validator. + + :param get_validator: Callable that returns a :class:`.Validator` instance. + """ + + def __init__(self, get_auto_suggest: Callable[[], AutoSuggest | None]) -> None: + self.get_auto_suggest = get_auto_suggest + + def get_suggestion(self, buff: Buffer, document: Document) -> Suggestion | None: + auto_suggest = self.get_auto_suggest() or DummyAutoSuggest() + return auto_suggest.get_suggestion(buff, document) + + async def get_suggestion_async( + self, buff: Buffer, document: Document + ) -> Suggestion | None: + auto_suggest = self.get_auto_suggest() or DummyAutoSuggest() + return await auto_suggest.get_suggestion_async(buff, document) diff --git a/src/prompt_toolkit/buffer.py b/src/prompt_toolkit/buffer.py new file mode 100644 index 0000000..100ca78 --- /dev/null +++ b/src/prompt_toolkit/buffer.py @@ -0,0 +1,2026 @@ +""" +Data structures for the Buffer. +It holds the text, cursor position, history, etc... +""" +from __future__ import annotations + +import asyncio +import logging +import os +import re +import shlex +import shutil +import subprocess +import tempfile +from collections import deque +from enum import Enum +from functools import wraps +from typing import Any, Callable, Coroutine, Iterable, TypeVar, cast + +from .application.current import get_app +from .application.run_in_terminal import run_in_terminal +from .auto_suggest import AutoSuggest, Suggestion +from .cache import FastDictCache +from .clipboard import ClipboardData +from .completion import ( + CompleteEvent, + Completer, + Completion, + DummyCompleter, + get_common_complete_suffix, +) +from .document import Document +from .eventloop import aclosing +from .filters import FilterOrBool, to_filter +from .history import History, InMemoryHistory +from .search import SearchDirection, SearchState +from .selection import PasteMode, SelectionState, SelectionType +from .utils import Event, to_str +from .validation import ValidationError, Validator + +__all__ = [ + "EditReadOnlyBuffer", + "Buffer", + "CompletionState", + "indent", + "unindent", + "reshape_text", +] + +logger = logging.getLogger(__name__) + + +class EditReadOnlyBuffer(Exception): + "Attempt editing of read-only :class:`.Buffer`." + + +class ValidationState(Enum): + "The validation state of a buffer. This is set after the validation." + + VALID = "VALID" + INVALID = "INVALID" + UNKNOWN = "UNKNOWN" + + +class CompletionState: + """ + Immutable class that contains a completion state. + """ + + def __init__( + self, + original_document: Document, + completions: list[Completion] | None = None, + complete_index: int | None = None, + ) -> None: + #: Document as it was when the completion started. + self.original_document = original_document + + #: List of all the current Completion instances which are possible at + #: this point. + self.completions = completions or [] + + #: Position in the `completions` array. + #: This can be `None` to indicate "no completion", the original text. + self.complete_index = complete_index # Position in the `_completions` array. + + def __repr__(self) -> str: + return "{}({!r}, <{!r}> completions, index={!r})".format( + self.__class__.__name__, + self.original_document, + len(self.completions), + self.complete_index, + ) + + def go_to_index(self, index: int | None) -> None: + """ + Create a new :class:`.CompletionState` object with the new index. + + When `index` is `None` deselect the completion. + """ + if self.completions: + assert index is None or 0 <= index < len(self.completions) + self.complete_index = index + + def new_text_and_position(self) -> tuple[str, int]: + """ + Return (new_text, new_cursor_position) for this completion. + """ + if self.complete_index is None: + return self.original_document.text, self.original_document.cursor_position + else: + original_text_before_cursor = self.original_document.text_before_cursor + original_text_after_cursor = self.original_document.text_after_cursor + + c = self.completions[self.complete_index] + if c.start_position == 0: + before = original_text_before_cursor + else: + before = original_text_before_cursor[: c.start_position] + + new_text = before + c.text + original_text_after_cursor + new_cursor_position = len(before) + len(c.text) + return new_text, new_cursor_position + + @property + def current_completion(self) -> Completion | None: + """ + Return the current completion, or return `None` when no completion is + selected. + """ + if self.complete_index is not None: + return self.completions[self.complete_index] + return None + + +_QUOTED_WORDS_RE = re.compile(r"""(\s+|".*?"|'.*?')""") + + +class YankNthArgState: + """ + For yank-last-arg/yank-nth-arg: Keep track of where we are in the history. + """ + + def __init__( + self, history_position: int = 0, n: int = -1, previous_inserted_word: str = "" + ) -> None: + self.history_position = history_position + self.previous_inserted_word = previous_inserted_word + self.n = n + + def __repr__(self) -> str: + return "{}(history_position={!r}, n={!r}, previous_inserted_word={!r})".format( + self.__class__.__name__, + self.history_position, + self.n, + self.previous_inserted_word, + ) + + +BufferEventHandler = Callable[["Buffer"], None] +BufferAcceptHandler = Callable[["Buffer"], bool] + + +class Buffer: + """ + The core data structure that holds the text and cursor position of the + current input line and implements all text manipulations on top of it. It + also implements the history, undo stack and the completion state. + + :param completer: :class:`~prompt_toolkit.completion.Completer` instance. + :param history: :class:`~prompt_toolkit.history.History` instance. + :param tempfile_suffix: The tempfile suffix (extension) to be used for the + "open in editor" function. For a Python REPL, this would be ".py", so + that the editor knows the syntax highlighting to use. This can also be + a callable that returns a string. + :param tempfile: For more advanced tempfile situations where you need + control over the subdirectories and filename. For a Git Commit Message, + this would be ".git/COMMIT_EDITMSG", so that the editor knows the syntax + highlighting to use. This can also be a callable that returns a string. + :param name: Name for this buffer. E.g. DEFAULT_BUFFER. This is mostly + useful for key bindings where we sometimes prefer to refer to a buffer + by their name instead of by reference. + :param accept_handler: Called when the buffer input is accepted. (Usually + when the user presses `enter`.) The accept handler receives this + `Buffer` as input and should return True when the buffer text should be + kept instead of calling reset. + + In case of a `PromptSession` for instance, we want to keep the text, + because we will exit the application, and only reset it during the next + run. + + Events: + + :param on_text_changed: When the buffer text changes. (Callable or None.) + :param on_text_insert: When new text is inserted. (Callable or None.) + :param on_cursor_position_changed: When the cursor moves. (Callable or None.) + :param on_completions_changed: When the completions were changed. (Callable or None.) + :param on_suggestion_set: When an auto-suggestion text has been set. (Callable or None.) + + Filters: + + :param complete_while_typing: :class:`~prompt_toolkit.filters.Filter` + or `bool`. Decide whether or not to do asynchronous autocompleting while + typing. + :param validate_while_typing: :class:`~prompt_toolkit.filters.Filter` + or `bool`. Decide whether or not to do asynchronous validation while + typing. + :param enable_history_search: :class:`~prompt_toolkit.filters.Filter` or + `bool` to indicate when up-arrow partial string matching is enabled. It + is advised to not enable this at the same time as + `complete_while_typing`, because when there is an autocompletion found, + the up arrows usually browse through the completions, rather than + through the history. + :param read_only: :class:`~prompt_toolkit.filters.Filter`. When True, + changes will not be allowed. + :param multiline: :class:`~prompt_toolkit.filters.Filter` or `bool`. When + not set, pressing `Enter` will call the `accept_handler`. Otherwise, + pressing `Esc-Enter` is required. + """ + + def __init__( + self, + completer: Completer | None = None, + auto_suggest: AutoSuggest | None = None, + history: History | None = None, + validator: Validator | None = None, + tempfile_suffix: str | Callable[[], str] = "", + tempfile: str | Callable[[], str] = "", + name: str = "", + complete_while_typing: FilterOrBool = False, + validate_while_typing: FilterOrBool = False, + enable_history_search: FilterOrBool = False, + document: Document | None = None, + accept_handler: BufferAcceptHandler | None = None, + read_only: FilterOrBool = False, + multiline: FilterOrBool = True, + on_text_changed: BufferEventHandler | None = None, + on_text_insert: BufferEventHandler | None = None, + on_cursor_position_changed: BufferEventHandler | None = None, + on_completions_changed: BufferEventHandler | None = None, + on_suggestion_set: BufferEventHandler | None = None, + ): + # Accept both filters and booleans as input. + enable_history_search = to_filter(enable_history_search) + complete_while_typing = to_filter(complete_while_typing) + validate_while_typing = to_filter(validate_while_typing) + read_only = to_filter(read_only) + multiline = to_filter(multiline) + + self.completer = completer or DummyCompleter() + self.auto_suggest = auto_suggest + self.validator = validator + self.tempfile_suffix = tempfile_suffix + self.tempfile = tempfile + self.name = name + self.accept_handler = accept_handler + + # Filters. (Usually, used by the key bindings to drive the buffer.) + self.complete_while_typing = complete_while_typing + self.validate_while_typing = validate_while_typing + self.enable_history_search = enable_history_search + self.read_only = read_only + self.multiline = multiline + + # Text width. (For wrapping, used by the Vi 'gq' operator.) + self.text_width = 0 + + #: The command buffer history. + # Note that we shouldn't use a lazy 'or' here. bool(history) could be + # False when empty. + self.history = InMemoryHistory() if history is None else history + + self.__cursor_position = 0 + + # Events + self.on_text_changed: Event[Buffer] = Event(self, on_text_changed) + self.on_text_insert: Event[Buffer] = Event(self, on_text_insert) + self.on_cursor_position_changed: Event[Buffer] = Event( + self, on_cursor_position_changed + ) + self.on_completions_changed: Event[Buffer] = Event(self, on_completions_changed) + self.on_suggestion_set: Event[Buffer] = Event(self, on_suggestion_set) + + # Document cache. (Avoid creating new Document instances.) + self._document_cache: FastDictCache[ + tuple[str, int, SelectionState | None], Document + ] = FastDictCache(Document, size=10) + + # Create completer / auto suggestion / validation coroutines. + self._async_suggester = self._create_auto_suggest_coroutine() + self._async_completer = self._create_completer_coroutine() + self._async_validator = self._create_auto_validate_coroutine() + + # Asyncio task for populating the history. + self._load_history_task: asyncio.Future[None] | None = None + + # Reset other attributes. + self.reset(document=document) + + def __repr__(self) -> str: + if len(self.text) < 15: + text = self.text + else: + text = self.text[:12] + "..." + + return f"<Buffer(name={self.name!r}, text={text!r}) at {id(self)!r}>" + + def reset( + self, document: Document | None = None, append_to_history: bool = False + ) -> None: + """ + :param append_to_history: Append current input to history first. + """ + if append_to_history: + self.append_to_history() + + document = document or Document() + + self.__cursor_position = document.cursor_position + + # `ValidationError` instance. (Will be set when the input is wrong.) + self.validation_error: ValidationError | None = None + self.validation_state: ValidationState | None = ValidationState.UNKNOWN + + # State of the selection. + self.selection_state: SelectionState | None = None + + # Multiple cursor mode. (When we press 'I' or 'A' in visual-block mode, + # we can insert text on multiple lines at once. This is implemented by + # using multiple cursors.) + self.multiple_cursor_positions: list[int] = [] + + # When doing consecutive up/down movements, prefer to stay at this column. + self.preferred_column: int | None = None + + # State of complete browser + # For interactive completion through Ctrl-N/Ctrl-P. + self.complete_state: CompletionState | None = None + + # State of Emacs yank-nth-arg completion. + self.yank_nth_arg_state: YankNthArgState | None = None # for yank-nth-arg. + + # Remember the document that we had *right before* the last paste + # operation. This is used for rotating through the kill ring. + self.document_before_paste: Document | None = None + + # Current suggestion. + self.suggestion: Suggestion | None = None + + # The history search text. (Used for filtering the history when we + # browse through it.) + self.history_search_text: str | None = None + + # Undo/redo stacks (stack of `(text, cursor_position)`). + self._undo_stack: list[tuple[str, int]] = [] + self._redo_stack: list[tuple[str, int]] = [] + + # Cancel history loader. If history loading was still ongoing. + # Cancel the `_load_history_task`, so that next repaint of the + # `BufferControl` we will repopulate it. + if self._load_history_task is not None: + self._load_history_task.cancel() + self._load_history_task = None + + #: The working lines. Similar to history, except that this can be + #: modified. The user can press arrow_up and edit previous entries. + #: Ctrl-C should reset this, and copy the whole history back in here. + #: Enter should process the current command and append to the real + #: history. + self._working_lines: deque[str] = deque([document.text]) + self.__working_index = 0 + + def load_history_if_not_yet_loaded(self) -> None: + """ + Create task for populating the buffer history (if not yet done). + + Note:: + + This needs to be called from within the event loop of the + application, because history loading is async, and we need to be + sure the right event loop is active. Therefor, we call this method + in the `BufferControl.create_content`. + + There are situations where prompt_toolkit applications are created + in one thread, but will later run in a different thread (Ptpython + is one example. The REPL runs in a separate thread, in order to + prevent interfering with a potential different event loop in the + main thread. The REPL UI however is still created in the main + thread.) We could decide to not support creating prompt_toolkit + objects in one thread and running the application in a different + thread, but history loading is the only place where it matters, and + this solves it. + """ + if self._load_history_task is None: + + async def load_history() -> None: + async for item in self.history.load(): + self._working_lines.appendleft(item) + self.__working_index += 1 + + self._load_history_task = get_app().create_background_task(load_history()) + + def load_history_done(f: asyncio.Future[None]) -> None: + """ + Handle `load_history` result when either done, cancelled, or + when an exception was raised. + """ + try: + f.result() + except asyncio.CancelledError: + # Ignore cancellation. But handle it, so that we don't get + # this traceback. + pass + except GeneratorExit: + # Probably not needed, but we had situations where + # `GeneratorExit` was raised in `load_history` during + # cancellation. + pass + except BaseException: + # Log error if something goes wrong. (We don't have a + # caller to which we can propagate this exception.) + logger.exception("Loading history failed") + + self._load_history_task.add_done_callback(load_history_done) + + # <getters/setters> + + def _set_text(self, value: str) -> bool: + """set text at current working_index. Return whether it changed.""" + working_index = self.working_index + working_lines = self._working_lines + + original_value = working_lines[working_index] + working_lines[working_index] = value + + # Return True when this text has been changed. + if len(value) != len(original_value): + # For Python 2, it seems that when two strings have a different + # length and one is a prefix of the other, Python still scans + # character by character to see whether the strings are different. + # (Some benchmarking showed significant differences for big + # documents. >100,000 of lines.) + return True + elif value != original_value: + return True + return False + + def _set_cursor_position(self, value: int) -> bool: + """Set cursor position. Return whether it changed.""" + original_position = self.__cursor_position + self.__cursor_position = max(0, value) + + return self.__cursor_position != original_position + + @property + def text(self) -> str: + return self._working_lines[self.working_index] + + @text.setter + def text(self, value: str) -> None: + """ + Setting text. (When doing this, make sure that the cursor_position is + valid for this text. text/cursor_position should be consistent at any time, + otherwise set a Document instead.) + """ + # Ensure cursor position remains within the size of the text. + if self.cursor_position > len(value): + self.cursor_position = len(value) + + # Don't allow editing of read-only buffers. + if self.read_only(): + raise EditReadOnlyBuffer() + + changed = self._set_text(value) + + if changed: + self._text_changed() + + # Reset history search text. + # (Note that this doesn't need to happen when working_index + # changes, which is when we traverse the history. That's why we + # don't do this in `self._text_changed`.) + self.history_search_text = None + + @property + def cursor_position(self) -> int: + return self.__cursor_position + + @cursor_position.setter + def cursor_position(self, value: int) -> None: + """ + Setting cursor position. + """ + assert isinstance(value, int) + + # Ensure cursor position is within the size of the text. + if value > len(self.text): + value = len(self.text) + if value < 0: + value = 0 + + changed = self._set_cursor_position(value) + + if changed: + self._cursor_position_changed() + + @property + def working_index(self) -> int: + return self.__working_index + + @working_index.setter + def working_index(self, value: int) -> None: + if self.__working_index != value: + self.__working_index = value + # Make sure to reset the cursor position, otherwise we end up in + # situations where the cursor position is out of the bounds of the + # text. + self.cursor_position = 0 + self._text_changed() + + def _text_changed(self) -> None: + # Remove any validation errors and complete state. + self.validation_error = None + self.validation_state = ValidationState.UNKNOWN + self.complete_state = None + self.yank_nth_arg_state = None + self.document_before_paste = None + self.selection_state = None + self.suggestion = None + self.preferred_column = None + + # fire 'on_text_changed' event. + self.on_text_changed.fire() + + # Input validation. + # (This happens on all change events, unlike auto completion, also when + # deleting text.) + if self.validator and self.validate_while_typing(): + get_app().create_background_task(self._async_validator()) + + def _cursor_position_changed(self) -> None: + # Remove any complete state. + # (Input validation should only be undone when the cursor position + # changes.) + self.complete_state = None + self.yank_nth_arg_state = None + self.document_before_paste = None + + # Unset preferred_column. (Will be set after the cursor movement, if + # required.) + self.preferred_column = None + + # Note that the cursor position can change if we have a selection the + # new position of the cursor determines the end of the selection. + + # fire 'on_cursor_position_changed' event. + self.on_cursor_position_changed.fire() + + @property + def document(self) -> Document: + """ + Return :class:`~prompt_toolkit.document.Document` instance from the + current text, cursor position and selection state. + """ + return self._document_cache[ + self.text, self.cursor_position, self.selection_state + ] + + @document.setter + def document(self, value: Document) -> None: + """ + Set :class:`~prompt_toolkit.document.Document` instance. + + This will set both the text and cursor position at the same time, but + atomically. (Change events will be triggered only after both have been set.) + """ + self.set_document(value) + + def set_document(self, value: Document, bypass_readonly: bool = False) -> None: + """ + Set :class:`~prompt_toolkit.document.Document` instance. Like the + ``document`` property, but accept an ``bypass_readonly`` argument. + + :param bypass_readonly: When True, don't raise an + :class:`.EditReadOnlyBuffer` exception, even + when the buffer is read-only. + + .. warning:: + + When this buffer is read-only and `bypass_readonly` was not passed, + the `EditReadOnlyBuffer` exception will be caught by the + `KeyProcessor` and is silently suppressed. This is important to + keep in mind when writing key bindings, because it won't do what + you expect, and there won't be a stack trace. Use try/finally + around this function if you need some cleanup code. + """ + # Don't allow editing of read-only buffers. + if not bypass_readonly and self.read_only(): + raise EditReadOnlyBuffer() + + # Set text and cursor position first. + text_changed = self._set_text(value.text) + cursor_position_changed = self._set_cursor_position(value.cursor_position) + + # Now handle change events. (We do this when text/cursor position is + # both set and consistent.) + if text_changed: + self._text_changed() + self.history_search_text = None + + if cursor_position_changed: + self._cursor_position_changed() + + @property + def is_returnable(self) -> bool: + """ + True when there is something handling accept. + """ + return bool(self.accept_handler) + + # End of <getters/setters> + + def save_to_undo_stack(self, clear_redo_stack: bool = True) -> None: + """ + Safe current state (input text and cursor position), so that we can + restore it by calling undo. + """ + # Safe if the text is different from the text at the top of the stack + # is different. If the text is the same, just update the cursor position. + if self._undo_stack and self._undo_stack[-1][0] == self.text: + self._undo_stack[-1] = (self._undo_stack[-1][0], self.cursor_position) + else: + self._undo_stack.append((self.text, self.cursor_position)) + + # Saving anything to the undo stack, clears the redo stack. + if clear_redo_stack: + self._redo_stack = [] + + def transform_lines( + self, + line_index_iterator: Iterable[int], + transform_callback: Callable[[str], str], + ) -> str: + """ + Transforms the text on a range of lines. + When the iterator yield an index not in the range of lines that the + document contains, it skips them silently. + + To uppercase some lines:: + + new_text = transform_lines(range(5,10), lambda text: text.upper()) + + :param line_index_iterator: Iterator of line numbers (int) + :param transform_callback: callable that takes the original text of a + line, and return the new text for this line. + + :returns: The new text. + """ + # Split lines + lines = self.text.split("\n") + + # Apply transformation + for index in line_index_iterator: + try: + lines[index] = transform_callback(lines[index]) + except IndexError: + pass + + return "\n".join(lines) + + def transform_current_line(self, transform_callback: Callable[[str], str]) -> None: + """ + Apply the given transformation function to the current line. + + :param transform_callback: callable that takes a string and return a new string. + """ + document = self.document + a = document.cursor_position + document.get_start_of_line_position() + b = document.cursor_position + document.get_end_of_line_position() + self.text = ( + document.text[:a] + + transform_callback(document.text[a:b]) + + document.text[b:] + ) + + def transform_region( + self, from_: int, to: int, transform_callback: Callable[[str], str] + ) -> None: + """ + Transform a part of the input string. + + :param from_: (int) start position. + :param to: (int) end position. + :param transform_callback: Callable which accepts a string and returns + the transformed string. + """ + assert from_ < to + + self.text = "".join( + [ + self.text[:from_] + + transform_callback(self.text[from_:to]) + + self.text[to:] + ] + ) + + def cursor_left(self, count: int = 1) -> None: + self.cursor_position += self.document.get_cursor_left_position(count=count) + + def cursor_right(self, count: int = 1) -> None: + self.cursor_position += self.document.get_cursor_right_position(count=count) + + def cursor_up(self, count: int = 1) -> None: + """(for multiline edit). Move cursor to the previous line.""" + original_column = self.preferred_column or self.document.cursor_position_col + self.cursor_position += self.document.get_cursor_up_position( + count=count, preferred_column=original_column + ) + + # Remember the original column for the next up/down movement. + self.preferred_column = original_column + + def cursor_down(self, count: int = 1) -> None: + """(for multiline edit). Move cursor to the next line.""" + original_column = self.preferred_column or self.document.cursor_position_col + self.cursor_position += self.document.get_cursor_down_position( + count=count, preferred_column=original_column + ) + + # Remember the original column for the next up/down movement. + self.preferred_column = original_column + + def auto_up( + self, count: int = 1, go_to_start_of_line_if_history_changes: bool = False + ) -> None: + """ + If we're not on the first line (of a multiline input) go a line up, + otherwise go back in history. (If nothing is selected.) + """ + if self.complete_state: + self.complete_previous(count=count) + elif self.document.cursor_position_row > 0: + self.cursor_up(count=count) + elif not self.selection_state: + self.history_backward(count=count) + + # Go to the start of the line? + if go_to_start_of_line_if_history_changes: + self.cursor_position += self.document.get_start_of_line_position() + + def auto_down( + self, count: int = 1, go_to_start_of_line_if_history_changes: bool = False + ) -> None: + """ + If we're not on the last line (of a multiline input) go a line down, + otherwise go forward in history. (If nothing is selected.) + """ + if self.complete_state: + self.complete_next(count=count) + elif self.document.cursor_position_row < self.document.line_count - 1: + self.cursor_down(count=count) + elif not self.selection_state: + self.history_forward(count=count) + + # Go to the start of the line? + if go_to_start_of_line_if_history_changes: + self.cursor_position += self.document.get_start_of_line_position() + + def delete_before_cursor(self, count: int = 1) -> str: + """ + Delete specified number of characters before cursor and return the + deleted text. + """ + assert count >= 0 + deleted = "" + + if self.cursor_position > 0: + deleted = self.text[self.cursor_position - count : self.cursor_position] + + new_text = ( + self.text[: self.cursor_position - count] + + self.text[self.cursor_position :] + ) + new_cursor_position = self.cursor_position - len(deleted) + + # Set new Document atomically. + self.document = Document(new_text, new_cursor_position) + + return deleted + + def delete(self, count: int = 1) -> str: + """ + Delete specified number of characters and Return the deleted text. + """ + if self.cursor_position < len(self.text): + deleted = self.document.text_after_cursor[:count] + self.text = ( + self.text[: self.cursor_position] + + self.text[self.cursor_position + len(deleted) :] + ) + return deleted + else: + return "" + + def join_next_line(self, separator: str = " ") -> None: + """ + Join the next line to the current one by deleting the line ending after + the current line. + """ + if not self.document.on_last_line: + self.cursor_position += self.document.get_end_of_line_position() + self.delete() + + # Remove spaces. + self.text = ( + self.document.text_before_cursor + + separator + + self.document.text_after_cursor.lstrip(" ") + ) + + def join_selected_lines(self, separator: str = " ") -> None: + """ + Join the selected lines. + """ + assert self.selection_state + + # Get lines. + from_, to = sorted( + [self.cursor_position, self.selection_state.original_cursor_position] + ) + + before = self.text[:from_] + lines = self.text[from_:to].splitlines() + after = self.text[to:] + + # Replace leading spaces with just one space. + lines = [l.lstrip(" ") + separator for l in lines] + + # Set new document. + self.document = Document( + text=before + "".join(lines) + after, + cursor_position=len(before + "".join(lines[:-1])) - 1, + ) + + def swap_characters_before_cursor(self) -> None: + """ + Swap the last two characters before the cursor. + """ + pos = self.cursor_position + + if pos >= 2: + a = self.text[pos - 2] + b = self.text[pos - 1] + + self.text = self.text[: pos - 2] + b + a + self.text[pos:] + + def go_to_history(self, index: int) -> None: + """ + Go to this item in the history. + """ + if index < len(self._working_lines): + self.working_index = index + self.cursor_position = len(self.text) + + def complete_next(self, count: int = 1, disable_wrap_around: bool = False) -> None: + """ + Browse to the next completions. + (Does nothing if there are no completion.) + """ + index: int | None + + if self.complete_state: + completions_count = len(self.complete_state.completions) + + if self.complete_state.complete_index is None: + index = 0 + elif self.complete_state.complete_index == completions_count - 1: + index = None + + if disable_wrap_around: + return + else: + index = min( + completions_count - 1, self.complete_state.complete_index + count + ) + self.go_to_completion(index) + + def complete_previous( + self, count: int = 1, disable_wrap_around: bool = False + ) -> None: + """ + Browse to the previous completions. + (Does nothing if there are no completion.) + """ + index: int | None + + if self.complete_state: + if self.complete_state.complete_index == 0: + index = None + + if disable_wrap_around: + return + elif self.complete_state.complete_index is None: + index = len(self.complete_state.completions) - 1 + else: + index = max(0, self.complete_state.complete_index - count) + + self.go_to_completion(index) + + def cancel_completion(self) -> None: + """ + Cancel completion, go back to the original text. + """ + if self.complete_state: + self.go_to_completion(None) + self.complete_state = None + + def _set_completions(self, completions: list[Completion]) -> CompletionState: + """ + Start completions. (Generate list of completions and initialize.) + + By default, no completion will be selected. + """ + self.complete_state = CompletionState( + original_document=self.document, completions=completions + ) + + # Trigger event. This should eventually invalidate the layout. + self.on_completions_changed.fire() + + return self.complete_state + + def start_history_lines_completion(self) -> None: + """ + Start a completion based on all the other lines in the document and the + history. + """ + found_completions: set[str] = set() + completions = [] + + # For every line of the whole history, find matches with the current line. + current_line = self.document.current_line_before_cursor.lstrip() + + for i, string in enumerate(self._working_lines): + for j, l in enumerate(string.split("\n")): + l = l.strip() + if l and l.startswith(current_line): + # When a new line has been found. + if l not in found_completions: + found_completions.add(l) + + # Create completion. + if i == self.working_index: + display_meta = "Current, line %s" % (j + 1) + else: + display_meta = f"History {i + 1}, line {j + 1}" + + completions.append( + Completion( + text=l, + start_position=-len(current_line), + display_meta=display_meta, + ) + ) + + self._set_completions(completions=completions[::-1]) + self.go_to_completion(0) + + def go_to_completion(self, index: int | None) -> None: + """ + Select a completion from the list of current completions. + """ + assert self.complete_state + + # Set new completion + state = self.complete_state + state.go_to_index(index) + + # Set text/cursor position + new_text, new_cursor_position = state.new_text_and_position() + self.document = Document(new_text, new_cursor_position) + + # (changing text/cursor position will unset complete_state.) + self.complete_state = state + + def apply_completion(self, completion: Completion) -> None: + """ + Insert a given completion. + """ + # If there was already a completion active, cancel that one. + if self.complete_state: + self.go_to_completion(None) + self.complete_state = None + + # Insert text from the given completion. + self.delete_before_cursor(-completion.start_position) + self.insert_text(completion.text) + + def _set_history_search(self) -> None: + """ + Set `history_search_text`. + (The text before the cursor will be used for filtering the history.) + """ + if self.enable_history_search(): + if self.history_search_text is None: + self.history_search_text = self.document.text_before_cursor + else: + self.history_search_text = None + + def _history_matches(self, i: int) -> bool: + """ + True when the current entry matches the history search. + (when we don't have history search, it's also True.) + """ + return self.history_search_text is None or self._working_lines[i].startswith( + self.history_search_text + ) + + def history_forward(self, count: int = 1) -> None: + """ + Move forwards through the history. + + :param count: Amount of items to move forward. + """ + self._set_history_search() + + # Go forward in history. + found_something = False + + for i in range(self.working_index + 1, len(self._working_lines)): + if self._history_matches(i): + self.working_index = i + count -= 1 + found_something = True + if count == 0: + break + + # If we found an entry, move cursor to the end of the first line. + if found_something: + self.cursor_position = 0 + self.cursor_position += self.document.get_end_of_line_position() + + def history_backward(self, count: int = 1) -> None: + """ + Move backwards through history. + """ + self._set_history_search() + + # Go back in history. + found_something = False + + for i in range(self.working_index - 1, -1, -1): + if self._history_matches(i): + self.working_index = i + count -= 1 + found_something = True + if count == 0: + break + + # If we move to another entry, move cursor to the end of the line. + if found_something: + self.cursor_position = len(self.text) + + def yank_nth_arg(self, n: int | None = None, _yank_last_arg: bool = False) -> None: + """ + Pick nth word from previous history entry (depending on current + `yank_nth_arg_state`) and insert it at current position. Rotate through + history if called repeatedly. If no `n` has been given, take the first + argument. (The second word.) + + :param n: (None or int), The index of the word from the previous line + to take. + """ + assert n is None or isinstance(n, int) + history_strings = self.history.get_strings() + + if not len(history_strings): + return + + # Make sure we have a `YankNthArgState`. + if self.yank_nth_arg_state is None: + state = YankNthArgState(n=-1 if _yank_last_arg else 1) + else: + state = self.yank_nth_arg_state + + if n is not None: + state.n = n + + # Get new history position. + new_pos = state.history_position - 1 + if -new_pos > len(history_strings): + new_pos = -1 + + # Take argument from line. + line = history_strings[new_pos] + + words = [w.strip() for w in _QUOTED_WORDS_RE.split(line)] + words = [w for w in words if w] + try: + word = words[state.n] + except IndexError: + word = "" + + # Insert new argument. + if state.previous_inserted_word: + self.delete_before_cursor(len(state.previous_inserted_word)) + self.insert_text(word) + + # Save state again for next completion. (Note that the 'insert' + # operation from above clears `self.yank_nth_arg_state`.) + state.previous_inserted_word = word + state.history_position = new_pos + self.yank_nth_arg_state = state + + def yank_last_arg(self, n: int | None = None) -> None: + """ + Like `yank_nth_arg`, but if no argument has been given, yank the last + word by default. + """ + self.yank_nth_arg(n=n, _yank_last_arg=True) + + def start_selection( + self, selection_type: SelectionType = SelectionType.CHARACTERS + ) -> None: + """ + Take the current cursor position as the start of this selection. + """ + self.selection_state = SelectionState(self.cursor_position, selection_type) + + def copy_selection(self, _cut: bool = False) -> ClipboardData: + """ + Copy selected text and return :class:`.ClipboardData` instance. + + Notice that this doesn't store the copied data on the clipboard yet. + You can store it like this: + + .. code:: python + + data = buffer.copy_selection() + get_app().clipboard.set_data(data) + """ + new_document, clipboard_data = self.document.cut_selection() + if _cut: + self.document = new_document + + self.selection_state = None + return clipboard_data + + def cut_selection(self) -> ClipboardData: + """ + Delete selected text and return :class:`.ClipboardData` instance. + """ + return self.copy_selection(_cut=True) + + def paste_clipboard_data( + self, + data: ClipboardData, + paste_mode: PasteMode = PasteMode.EMACS, + count: int = 1, + ) -> None: + """ + Insert the data from the clipboard. + """ + assert isinstance(data, ClipboardData) + assert paste_mode in (PasteMode.VI_BEFORE, PasteMode.VI_AFTER, PasteMode.EMACS) + + original_document = self.document + self.document = self.document.paste_clipboard_data( + data, paste_mode=paste_mode, count=count + ) + + # Remember original document. This assignment should come at the end, + # because assigning to 'document' will erase it. + self.document_before_paste = original_document + + def newline(self, copy_margin: bool = True) -> None: + """ + Insert a line ending at the current position. + """ + if copy_margin: + self.insert_text("\n" + self.document.leading_whitespace_in_current_line) + else: + self.insert_text("\n") + + def insert_line_above(self, copy_margin: bool = True) -> None: + """ + Insert a new line above the current one. + """ + if copy_margin: + insert = self.document.leading_whitespace_in_current_line + "\n" + else: + insert = "\n" + + self.cursor_position += self.document.get_start_of_line_position() + self.insert_text(insert) + self.cursor_position -= 1 + + def insert_line_below(self, copy_margin: bool = True) -> None: + """ + Insert a new line below the current one. + """ + if copy_margin: + insert = "\n" + self.document.leading_whitespace_in_current_line + else: + insert = "\n" + + self.cursor_position += self.document.get_end_of_line_position() + self.insert_text(insert) + + def insert_text( + self, + data: str, + overwrite: bool = False, + move_cursor: bool = True, + fire_event: bool = True, + ) -> None: + """ + Insert characters at cursor position. + + :param fire_event: Fire `on_text_insert` event. This is mainly used to + trigger autocompletion while typing. + """ + # Original text & cursor position. + otext = self.text + ocpos = self.cursor_position + + # In insert/text mode. + if overwrite: + # Don't overwrite the newline itself. Just before the line ending, + # it should act like insert mode. + overwritten_text = otext[ocpos : ocpos + len(data)] + if "\n" in overwritten_text: + overwritten_text = overwritten_text[: overwritten_text.find("\n")] + + text = otext[:ocpos] + data + otext[ocpos + len(overwritten_text) :] + else: + text = otext[:ocpos] + data + otext[ocpos:] + + if move_cursor: + cpos = self.cursor_position + len(data) + else: + cpos = self.cursor_position + + # Set new document. + # (Set text and cursor position at the same time. Otherwise, setting + # the text will fire a change event before the cursor position has been + # set. It works better to have this atomic.) + self.document = Document(text, cpos) + + # Fire 'on_text_insert' event. + if fire_event: # XXX: rename to `start_complete`. + self.on_text_insert.fire() + + # Only complete when "complete_while_typing" is enabled. + if self.completer and self.complete_while_typing(): + get_app().create_background_task(self._async_completer()) + + # Call auto_suggest. + if self.auto_suggest: + get_app().create_background_task(self._async_suggester()) + + def undo(self) -> None: + # Pop from the undo-stack until we find a text that if different from + # the current text. (The current logic of `save_to_undo_stack` will + # cause that the top of the undo stack is usually the same as the + # current text, so in that case we have to pop twice.) + while self._undo_stack: + text, pos = self._undo_stack.pop() + + if text != self.text: + # Push current text to redo stack. + self._redo_stack.append((self.text, self.cursor_position)) + + # Set new text/cursor_position. + self.document = Document(text, cursor_position=pos) + break + + def redo(self) -> None: + if self._redo_stack: + # Copy current state on undo stack. + self.save_to_undo_stack(clear_redo_stack=False) + + # Pop state from redo stack. + text, pos = self._redo_stack.pop() + self.document = Document(text, cursor_position=pos) + + def validate(self, set_cursor: bool = False) -> bool: + """ + Returns `True` if valid. + + :param set_cursor: Set the cursor position, if an error was found. + """ + # Don't call the validator again, if it was already called for the + # current input. + if self.validation_state != ValidationState.UNKNOWN: + return self.validation_state == ValidationState.VALID + + # Call validator. + if self.validator: + try: + self.validator.validate(self.document) + except ValidationError as e: + # Set cursor position (don't allow invalid values.) + if set_cursor: + self.cursor_position = min( + max(0, e.cursor_position), len(self.text) + ) + + self.validation_state = ValidationState.INVALID + self.validation_error = e + return False + + # Handle validation result. + self.validation_state = ValidationState.VALID + self.validation_error = None + return True + + async def _validate_async(self) -> None: + """ + Asynchronous version of `validate()`. + This one doesn't set the cursor position. + + We have both variants, because a synchronous version is required. + Handling the ENTER key needs to be completely synchronous, otherwise + stuff like type-ahead is going to give very weird results. (People + could type input while the ENTER key is still processed.) + + An asynchronous version is required if we have `validate_while_typing` + enabled. + """ + while True: + # Don't call the validator again, if it was already called for the + # current input. + if self.validation_state != ValidationState.UNKNOWN: + return + + # Call validator. + error = None + document = self.document + + if self.validator: + try: + await self.validator.validate_async(self.document) + except ValidationError as e: + error = e + + # If the document changed during the validation, try again. + if self.document != document: + continue + + # Handle validation result. + if error: + self.validation_state = ValidationState.INVALID + else: + self.validation_state = ValidationState.VALID + + self.validation_error = error + get_app().invalidate() # Trigger redraw (display error). + + def append_to_history(self) -> None: + """ + Append the current input to the history. + """ + # Save at the tail of the history. (But don't if the last entry the + # history is already the same.) + if self.text: + history_strings = self.history.get_strings() + if not len(history_strings) or history_strings[-1] != self.text: + self.history.append_string(self.text) + + def _search( + self, + search_state: SearchState, + include_current_position: bool = False, + count: int = 1, + ) -> tuple[int, int] | None: + """ + Execute search. Return (working_index, cursor_position) tuple when this + search is applied. Returns `None` when this text cannot be found. + """ + assert count > 0 + + text = search_state.text + direction = search_state.direction + ignore_case = search_state.ignore_case() + + def search_once( + working_index: int, document: Document + ) -> tuple[int, Document] | None: + """ + Do search one time. + Return (working_index, document) or `None` + """ + if direction == SearchDirection.FORWARD: + # Try find at the current input. + new_index = document.find( + text, + include_current_position=include_current_position, + ignore_case=ignore_case, + ) + + if new_index is not None: + return ( + working_index, + Document(document.text, document.cursor_position + new_index), + ) + else: + # No match, go forward in the history. (Include len+1 to wrap around.) + # (Here we should always include all cursor positions, because + # it's a different line.) + for i in range(working_index + 1, len(self._working_lines) + 1): + i %= len(self._working_lines) + + document = Document(self._working_lines[i], 0) + new_index = document.find( + text, include_current_position=True, ignore_case=ignore_case + ) + if new_index is not None: + return (i, Document(document.text, new_index)) + else: + # Try find at the current input. + new_index = document.find_backwards(text, ignore_case=ignore_case) + + if new_index is not None: + return ( + working_index, + Document(document.text, document.cursor_position + new_index), + ) + else: + # No match, go back in the history. (Include -1 to wrap around.) + for i in range(working_index - 1, -2, -1): + i %= len(self._working_lines) + + document = Document( + self._working_lines[i], len(self._working_lines[i]) + ) + new_index = document.find_backwards( + text, ignore_case=ignore_case + ) + if new_index is not None: + return ( + i, + Document(document.text, len(document.text) + new_index), + ) + return None + + # Do 'count' search iterations. + working_index = self.working_index + document = self.document + for _ in range(count): + result = search_once(working_index, document) + if result is None: + return None # Nothing found. + else: + working_index, document = result + + return (working_index, document.cursor_position) + + def document_for_search(self, search_state: SearchState) -> Document: + """ + Return a :class:`~prompt_toolkit.document.Document` instance that has + the text/cursor position for this search, if we would apply it. This + will be used in the + :class:`~prompt_toolkit.layout.BufferControl` to display feedback while + searching. + """ + search_result = self._search(search_state, include_current_position=True) + + if search_result is None: + return self.document + else: + working_index, cursor_position = search_result + + # Keep selection, when `working_index` was not changed. + if working_index == self.working_index: + selection = self.selection_state + else: + selection = None + + return Document( + self._working_lines[working_index], cursor_position, selection=selection + ) + + def get_search_position( + self, + search_state: SearchState, + include_current_position: bool = True, + count: int = 1, + ) -> int: + """ + Get the cursor position for this search. + (This operation won't change the `working_index`. It's won't go through + the history. Vi text objects can't span multiple items.) + """ + search_result = self._search( + search_state, include_current_position=include_current_position, count=count + ) + + if search_result is None: + return self.cursor_position + else: + working_index, cursor_position = search_result + return cursor_position + + def apply_search( + self, + search_state: SearchState, + include_current_position: bool = True, + count: int = 1, + ) -> None: + """ + Apply search. If something is found, set `working_index` and + `cursor_position`. + """ + search_result = self._search( + search_state, include_current_position=include_current_position, count=count + ) + + if search_result is not None: + working_index, cursor_position = search_result + self.working_index = working_index + self.cursor_position = cursor_position + + def exit_selection(self) -> None: + self.selection_state = None + + def _editor_simple_tempfile(self) -> tuple[str, Callable[[], None]]: + """ + Simple (file) tempfile implementation. + Return (tempfile, cleanup_func). + """ + suffix = to_str(self.tempfile_suffix) + descriptor, filename = tempfile.mkstemp(suffix) + + os.write(descriptor, self.text.encode("utf-8")) + os.close(descriptor) + + def cleanup() -> None: + os.unlink(filename) + + return filename, cleanup + + def _editor_complex_tempfile(self) -> tuple[str, Callable[[], None]]: + # Complex (directory) tempfile implementation. + headtail = to_str(self.tempfile) + if not headtail: + # Revert to simple case. + return self._editor_simple_tempfile() + headtail = str(headtail) + + # Try to make according to tempfile logic. + head, tail = os.path.split(headtail) + if os.path.isabs(head): + head = head[1:] + + dirpath = tempfile.mkdtemp() + if head: + dirpath = os.path.join(dirpath, head) + # Assume there is no issue creating dirs in this temp dir. + os.makedirs(dirpath) + + # Open the filename and write current text. + filename = os.path.join(dirpath, tail) + with open(filename, "w", encoding="utf-8") as fh: + fh.write(self.text) + + def cleanup() -> None: + shutil.rmtree(dirpath) + + return filename, cleanup + + def open_in_editor(self, validate_and_handle: bool = False) -> asyncio.Task[None]: + """ + Open code in editor. + + This returns a future, and runs in a thread executor. + """ + if self.read_only(): + raise EditReadOnlyBuffer() + + # Write current text to temporary file + if self.tempfile: + filename, cleanup_func = self._editor_complex_tempfile() + else: + filename, cleanup_func = self._editor_simple_tempfile() + + async def run() -> None: + try: + # Open in editor + # (We need to use `run_in_terminal`, because not all editors go to + # the alternate screen buffer, and some could influence the cursor + # position.) + success = await run_in_terminal( + lambda: self._open_file_in_editor(filename), in_executor=True + ) + + # Read content again. + if success: + with open(filename, "rb") as f: + text = f.read().decode("utf-8") + + # Drop trailing newline. (Editors are supposed to add it at the + # end, but we don't need it.) + if text.endswith("\n"): + text = text[:-1] + + self.document = Document(text=text, cursor_position=len(text)) + + # Accept the input. + if validate_and_handle: + self.validate_and_handle() + + finally: + # Clean up temp dir/file. + cleanup_func() + + return get_app().create_background_task(run()) + + def _open_file_in_editor(self, filename: str) -> bool: + """ + Call editor executable. + + Return True when we received a zero return code. + """ + # If the 'VISUAL' or 'EDITOR' environment variable has been set, use that. + # Otherwise, fall back to the first available editor that we can find. + visual = os.environ.get("VISUAL") + editor = os.environ.get("EDITOR") + + editors = [ + visual, + editor, + # Order of preference. + "/usr/bin/editor", + "/usr/bin/nano", + "/usr/bin/pico", + "/usr/bin/vi", + "/usr/bin/emacs", + ] + + for e in editors: + if e: + try: + # Use 'shlex.split()', because $VISUAL can contain spaces + # and quotes. + returncode = subprocess.call(shlex.split(e) + [filename]) + return returncode == 0 + + except OSError: + # Executable does not exist, try the next one. + pass + + return False + + def start_completion( + self, + select_first: bool = False, + select_last: bool = False, + insert_common_part: bool = False, + complete_event: CompleteEvent | None = None, + ) -> None: + """ + Start asynchronous autocompletion of this buffer. + (This will do nothing if a previous completion was still in progress.) + """ + # Only one of these options can be selected. + assert select_first + select_last + insert_common_part <= 1 + + get_app().create_background_task( + self._async_completer( + select_first=select_first, + select_last=select_last, + insert_common_part=insert_common_part, + complete_event=complete_event + or CompleteEvent(completion_requested=True), + ) + ) + + def _create_completer_coroutine(self) -> Callable[..., Coroutine[Any, Any, None]]: + """ + Create function for asynchronous autocompletion. + + (This consumes the asynchronous completer generator, which possibly + runs the completion algorithm in another thread.) + """ + + def completion_does_nothing(document: Document, completion: Completion) -> bool: + """ + Return `True` if applying this completion doesn't have any effect. + (When it doesn't insert any new text. + """ + text_before_cursor = document.text_before_cursor + replaced_text = text_before_cursor[ + len(text_before_cursor) + completion.start_position : + ] + return replaced_text == completion.text + + @_only_one_at_a_time + async def async_completer( + select_first: bool = False, + select_last: bool = False, + insert_common_part: bool = False, + complete_event: CompleteEvent | None = None, + ) -> None: + document = self.document + complete_event = complete_event or CompleteEvent(text_inserted=True) + + # Don't complete when we already have completions. + if self.complete_state or not self.completer: + return + + # Create an empty CompletionState. + complete_state = CompletionState(original_document=self.document) + self.complete_state = complete_state + + def proceed() -> bool: + """Keep retrieving completions. Input text has not yet changed + while generating completions.""" + return self.complete_state == complete_state + + refresh_needed = asyncio.Event() + + async def refresh_while_loading() -> None: + """Background loop to refresh the UI at most 3 times a second + while the completion are loading. Calling + `on_completions_changed.fire()` for every completion that we + receive is too expensive when there are many completions. (We + could tune `Application.max_render_postpone_time` and + `Application.min_redraw_interval`, but having this here is a + better approach.) + """ + while True: + self.on_completions_changed.fire() + refresh_needed.clear() + await asyncio.sleep(0.3) + await refresh_needed.wait() + + refresh_task = asyncio.ensure_future(refresh_while_loading()) + try: + # Load. + async with aclosing( + self.completer.get_completions_async(document, complete_event) + ) as async_generator: + async for completion in async_generator: + complete_state.completions.append(completion) + refresh_needed.set() + + # If the input text changes, abort. + if not proceed(): + break + finally: + refresh_task.cancel() + + # Refresh one final time after we got everything. + self.on_completions_changed.fire() + + completions = complete_state.completions + + # When there is only one completion, which has nothing to add, ignore it. + if len(completions) == 1 and completion_does_nothing( + document, completions[0] + ): + del completions[:] + + # Set completions if the text was not yet changed. + if proceed(): + # When no completions were found, or when the user selected + # already a completion by using the arrow keys, don't do anything. + if ( + not self.complete_state + or self.complete_state.complete_index is not None + ): + return + + # When there are no completions, reset completion state anyway. + if not completions: + self.complete_state = None + # Render the ui if the completion menu was shown + # it is needed especially if there is one completion and it was deleted. + self.on_completions_changed.fire() + return + + # Select first/last or insert common part, depending on the key + # binding. (For this we have to wait until all completions are + # loaded.) + + if select_first: + self.go_to_completion(0) + + elif select_last: + self.go_to_completion(len(completions) - 1) + + elif insert_common_part: + common_part = get_common_complete_suffix(document, completions) + if common_part: + # Insert the common part, update completions. + self.insert_text(common_part) + if len(completions) > 1: + # (Don't call `async_completer` again, but + # recalculate completions. See: + # https://github.com/ipython/ipython/issues/9658) + completions[:] = [ + c.new_completion_from_position(len(common_part)) + for c in completions + ] + + self._set_completions(completions=completions) + else: + self.complete_state = None + else: + # When we were asked to insert the "common" + # prefix, but there was no common suffix but + # still exactly one match, then select the + # first. (It could be that we have a completion + # which does * expansion, like '*.py', with + # exactly one match.) + if len(completions) == 1: + self.go_to_completion(0) + + else: + # If the last operation was an insert, (not a delete), restart + # the completion coroutine. + + if self.document.text_before_cursor == document.text_before_cursor: + return # Nothing changed. + + if self.document.text_before_cursor.startswith( + document.text_before_cursor + ): + raise _Retry + + return async_completer + + def _create_auto_suggest_coroutine(self) -> Callable[[], Coroutine[Any, Any, None]]: + """ + Create function for asynchronous auto suggestion. + (This can be in another thread.) + """ + + @_only_one_at_a_time + async def async_suggestor() -> None: + document = self.document + + # Don't suggest when we already have a suggestion. + if self.suggestion or not self.auto_suggest: + return + + suggestion = await self.auto_suggest.get_suggestion_async(self, document) + + # Set suggestion only if the text was not yet changed. + if self.document == document: + # Set suggestion and redraw interface. + self.suggestion = suggestion + self.on_suggestion_set.fire() + else: + # Otherwise, restart thread. + raise _Retry + + return async_suggestor + + def _create_auto_validate_coroutine( + self, + ) -> Callable[[], Coroutine[Any, Any, None]]: + """ + Create a function for asynchronous validation while typing. + (This can be in another thread.) + """ + + @_only_one_at_a_time + async def async_validator() -> None: + await self._validate_async() + + return async_validator + + def validate_and_handle(self) -> None: + """ + Validate buffer and handle the accept action. + """ + valid = self.validate(set_cursor=True) + + # When the validation succeeded, accept the input. + if valid: + if self.accept_handler: + keep_text = self.accept_handler(self) + else: + keep_text = False + + self.append_to_history() + + if not keep_text: + self.reset() + + +_T = TypeVar("_T", bound=Callable[..., Coroutine[Any, Any, None]]) + + +def _only_one_at_a_time(coroutine: _T) -> _T: + """ + Decorator that only starts the coroutine only if the previous call has + finished. (Used to make sure that we have only one autocompleter, auto + suggestor and validator running at a time.) + + When the coroutine raises `_Retry`, it is restarted. + """ + running = False + + @wraps(coroutine) + async def new_coroutine(*a: Any, **kw: Any) -> Any: + nonlocal running + + # Don't start a new function, if the previous is still in progress. + if running: + return + + running = True + + try: + while True: + try: + await coroutine(*a, **kw) + except _Retry: + continue + else: + return None + finally: + running = False + + return cast(_T, new_coroutine) + + +class _Retry(Exception): + "Retry in `_only_one_at_a_time`." + + +def indent(buffer: Buffer, from_row: int, to_row: int, count: int = 1) -> None: + """ + Indent text of a :class:`.Buffer` object. + """ + current_row = buffer.document.cursor_position_row + current_col = buffer.document.cursor_position_col + line_range = range(from_row, to_row) + + # Apply transformation. + indent_content = " " * count + new_text = buffer.transform_lines(line_range, lambda l: indent_content + l) + buffer.document = Document( + new_text, Document(new_text).translate_row_col_to_index(current_row, 0) + ) + + # Place cursor in the same position in text after indenting + buffer.cursor_position += current_col + len(indent_content) + + +def unindent(buffer: Buffer, from_row: int, to_row: int, count: int = 1) -> None: + """ + Unindent text of a :class:`.Buffer` object. + """ + current_row = buffer.document.cursor_position_row + current_col = buffer.document.cursor_position_col + line_range = range(from_row, to_row) + + indent_content = " " * count + + def transform(text: str) -> str: + remove = indent_content + if text.startswith(remove): + return text[len(remove) :] + else: + return text.lstrip() + + # Apply transformation. + new_text = buffer.transform_lines(line_range, transform) + buffer.document = Document( + new_text, Document(new_text).translate_row_col_to_index(current_row, 0) + ) + + # Place cursor in the same position in text after dedent + buffer.cursor_position += current_col - len(indent_content) + + +def reshape_text(buffer: Buffer, from_row: int, to_row: int) -> None: + """ + Reformat text, taking the width into account. + `to_row` is included. + (Vi 'gq' operator.) + """ + lines = buffer.text.splitlines(True) + lines_before = lines[:from_row] + lines_after = lines[to_row + 1 :] + lines_to_reformat = lines[from_row : to_row + 1] + + if lines_to_reformat: + # Take indentation from the first line. + match = re.search(r"^\s*", lines_to_reformat[0]) + length = match.end() if match else 0 # `match` can't be None, actually. + + indent = lines_to_reformat[0][:length].replace("\n", "") + + # Now, take all the 'words' from the lines to be reshaped. + words = "".join(lines_to_reformat).split() + + # And reshape. + width = (buffer.text_width or 80) - len(indent) + reshaped_text = [indent] + current_width = 0 + for w in words: + if current_width: + if len(w) + current_width + 1 > width: + reshaped_text.append("\n") + reshaped_text.append(indent) + current_width = 0 + else: + reshaped_text.append(" ") + current_width += 1 + + reshaped_text.append(w) + current_width += len(w) + + if reshaped_text[-1] != "\n": + reshaped_text.append("\n") + + # Apply result. + buffer.document = Document( + text="".join(lines_before + reshaped_text + lines_after), + cursor_position=len("".join(lines_before + reshaped_text)), + ) diff --git a/src/prompt_toolkit/cache.py b/src/prompt_toolkit/cache.py new file mode 100644 index 0000000..01dd1f7 --- /dev/null +++ b/src/prompt_toolkit/cache.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from collections import deque +from functools import wraps +from typing import Any, Callable, Dict, Generic, Hashable, Tuple, TypeVar, cast + +__all__ = [ + "SimpleCache", + "FastDictCache", + "memoized", +] + +_T = TypeVar("_T", bound=Hashable) +_U = TypeVar("_U") + + +class SimpleCache(Generic[_T, _U]): + """ + Very simple cache that discards the oldest item when the cache size is + exceeded. + + :param maxsize: Maximum size of the cache. (Don't make it too big.) + """ + + def __init__(self, maxsize: int = 8) -> None: + assert maxsize > 0 + + self._data: dict[_T, _U] = {} + self._keys: deque[_T] = deque() + self.maxsize: int = maxsize + + def get(self, key: _T, getter_func: Callable[[], _U]) -> _U: + """ + Get object from the cache. + If not found, call `getter_func` to resolve it, and put that on the top + of the cache instead. + """ + # Look in cache first. + try: + return self._data[key] + except KeyError: + # Not found? Get it. + value = getter_func() + self._data[key] = value + self._keys.append(key) + + # Remove the oldest key when the size is exceeded. + if len(self._data) > self.maxsize: + key_to_remove = self._keys.popleft() + if key_to_remove in self._data: + del self._data[key_to_remove] + + return value + + def clear(self) -> None: + "Clear cache." + self._data = {} + self._keys = deque() + + +_K = TypeVar("_K", bound=Tuple[Hashable, ...]) +_V = TypeVar("_V") + + +class FastDictCache(Dict[_K, _V]): + """ + Fast, lightweight cache which keeps at most `size` items. + It will discard the oldest items in the cache first. + + The cache is a dictionary, which doesn't keep track of access counts. + It is perfect to cache little immutable objects which are not expensive to + create, but where a dictionary lookup is still much faster than an object + instantiation. + + :param get_value: Callable that's called in case of a missing key. + """ + + # NOTE: This cache is used to cache `prompt_toolkit.layout.screen.Char` and + # `prompt_toolkit.Document`. Make sure to keep this really lightweight. + # Accessing the cache should stay faster than instantiating new + # objects. + # (Dictionary lookups are really fast.) + # SimpleCache is still required for cases where the cache key is not + # the same as the arguments given to the function that creates the + # value.) + def __init__(self, get_value: Callable[..., _V], size: int = 1000000) -> None: + assert size > 0 + + self._keys: deque[_K] = deque() + self.get_value = get_value + self.size = size + + def __missing__(self, key: _K) -> _V: + # Remove the oldest key when the size is exceeded. + if len(self) > self.size: + key_to_remove = self._keys.popleft() + if key_to_remove in self: + del self[key_to_remove] + + result = self.get_value(*key) + self[key] = result + self._keys.append(key) + return result + + +_F = TypeVar("_F", bound=Callable[..., object]) + + +def memoized(maxsize: int = 1024) -> Callable[[_F], _F]: + """ + Memoization decorator for immutable classes and pure functions. + """ + + def decorator(obj: _F) -> _F: + cache: SimpleCache[Hashable, Any] = SimpleCache(maxsize=maxsize) + + @wraps(obj) + def new_callable(*a: Any, **kw: Any) -> Any: + def create_new() -> Any: + return obj(*a, **kw) + + key = (a, tuple(sorted(kw.items()))) + return cache.get(key, create_new) + + return cast(_F, new_callable) + + return decorator diff --git a/src/prompt_toolkit/clipboard/__init__.py b/src/prompt_toolkit/clipboard/__init__.py new file mode 100644 index 0000000..e72f30e --- /dev/null +++ b/src/prompt_toolkit/clipboard/__init__.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from .base import Clipboard, ClipboardData, DummyClipboard, DynamicClipboard +from .in_memory import InMemoryClipboard + +# We are not importing `PyperclipClipboard` here, because it would require the +# `pyperclip` module to be present. + +# from .pyperclip import PyperclipClipboard + +__all__ = [ + "Clipboard", + "ClipboardData", + "DummyClipboard", + "DynamicClipboard", + "InMemoryClipboard", +] diff --git a/src/prompt_toolkit/clipboard/base.py b/src/prompt_toolkit/clipboard/base.py new file mode 100644 index 0000000..b05275b --- /dev/null +++ b/src/prompt_toolkit/clipboard/base.py @@ -0,0 +1,108 @@ +""" +Clipboard for command line interface. +""" +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from typing import Callable + +from prompt_toolkit.selection import SelectionType + +__all__ = [ + "Clipboard", + "ClipboardData", + "DummyClipboard", + "DynamicClipboard", +] + + +class ClipboardData: + """ + Text on the clipboard. + + :param text: string + :param type: :class:`~prompt_toolkit.selection.SelectionType` + """ + + def __init__( + self, text: str = "", type: SelectionType = SelectionType.CHARACTERS + ) -> None: + self.text = text + self.type = type + + +class Clipboard(metaclass=ABCMeta): + """ + Abstract baseclass for clipboards. + (An implementation can be in memory, it can share the X11 or Windows + keyboard, or can be persistent.) + """ + + @abstractmethod + def set_data(self, data: ClipboardData) -> None: + """ + Set data to the clipboard. + + :param data: :class:`~.ClipboardData` instance. + """ + + def set_text(self, text: str) -> None: # Not abstract. + """ + Shortcut for setting plain text on clipboard. + """ + self.set_data(ClipboardData(text)) + + def rotate(self) -> None: + """ + For Emacs mode, rotate the kill ring. + """ + + @abstractmethod + def get_data(self) -> ClipboardData: + """ + Return clipboard data. + """ + + +class DummyClipboard(Clipboard): + """ + Clipboard implementation that doesn't remember anything. + """ + + def set_data(self, data: ClipboardData) -> None: + pass + + def set_text(self, text: str) -> None: + pass + + def rotate(self) -> None: + pass + + def get_data(self) -> ClipboardData: + return ClipboardData() + + +class DynamicClipboard(Clipboard): + """ + Clipboard class that can dynamically returns any Clipboard. + + :param get_clipboard: Callable that returns a :class:`.Clipboard` instance. + """ + + def __init__(self, get_clipboard: Callable[[], Clipboard | None]) -> None: + self.get_clipboard = get_clipboard + + def _clipboard(self) -> Clipboard: + return self.get_clipboard() or DummyClipboard() + + def set_data(self, data: ClipboardData) -> None: + self._clipboard().set_data(data) + + def set_text(self, text: str) -> None: + self._clipboard().set_text(text) + + def rotate(self) -> None: + self._clipboard().rotate() + + def get_data(self) -> ClipboardData: + return self._clipboard().get_data() diff --git a/src/prompt_toolkit/clipboard/in_memory.py b/src/prompt_toolkit/clipboard/in_memory.py new file mode 100644 index 0000000..d9ae081 --- /dev/null +++ b/src/prompt_toolkit/clipboard/in_memory.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from collections import deque + +from .base import Clipboard, ClipboardData + +__all__ = [ + "InMemoryClipboard", +] + + +class InMemoryClipboard(Clipboard): + """ + Default clipboard implementation. + Just keep the data in memory. + + This implements a kill-ring, for Emacs mode. + """ + + def __init__(self, data: ClipboardData | None = None, max_size: int = 60) -> None: + assert max_size >= 1 + + self.max_size = max_size + self._ring: deque[ClipboardData] = deque() + + if data is not None: + self.set_data(data) + + def set_data(self, data: ClipboardData) -> None: + self._ring.appendleft(data) + + while len(self._ring) > self.max_size: + self._ring.pop() + + def get_data(self) -> ClipboardData: + if self._ring: + return self._ring[0] + else: + return ClipboardData() + + def rotate(self) -> None: + if self._ring: + # Add the very first item at the end. + self._ring.append(self._ring.popleft()) diff --git a/src/prompt_toolkit/clipboard/pyperclip.py b/src/prompt_toolkit/clipboard/pyperclip.py new file mode 100644 index 0000000..66eb711 --- /dev/null +++ b/src/prompt_toolkit/clipboard/pyperclip.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import pyperclip + +from prompt_toolkit.selection import SelectionType + +from .base import Clipboard, ClipboardData + +__all__ = [ + "PyperclipClipboard", +] + + +class PyperclipClipboard(Clipboard): + """ + Clipboard that synchronizes with the Windows/Mac/Linux system clipboard, + using the pyperclip module. + """ + + def __init__(self) -> None: + self._data: ClipboardData | None = None + + def set_data(self, data: ClipboardData) -> None: + self._data = data + pyperclip.copy(data.text) + + def get_data(self) -> ClipboardData: + text = pyperclip.paste() + + # When the clipboard data is equal to what we copied last time, reuse + # the `ClipboardData` instance. That way we're sure to keep the same + # `SelectionType`. + if self._data and self._data.text == text: + return self._data + + # Pyperclip returned something else. Create a new `ClipboardData` + # instance. + else: + return ClipboardData( + text=text, + type=SelectionType.LINES if "\n" in text else SelectionType.CHARACTERS, + ) diff --git a/src/prompt_toolkit/completion/__init__.py b/src/prompt_toolkit/completion/__init__.py new file mode 100644 index 0000000..f65a94e --- /dev/null +++ b/src/prompt_toolkit/completion/__init__.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from .base import ( + CompleteEvent, + Completer, + Completion, + ConditionalCompleter, + DummyCompleter, + DynamicCompleter, + ThreadedCompleter, + get_common_complete_suffix, + merge_completers, +) +from .deduplicate import DeduplicateCompleter +from .filesystem import ExecutableCompleter, PathCompleter +from .fuzzy_completer import FuzzyCompleter, FuzzyWordCompleter +from .nested import NestedCompleter +from .word_completer import WordCompleter + +__all__ = [ + # Base. + "Completion", + "Completer", + "ThreadedCompleter", + "DummyCompleter", + "DynamicCompleter", + "CompleteEvent", + "ConditionalCompleter", + "merge_completers", + "get_common_complete_suffix", + # Filesystem. + "PathCompleter", + "ExecutableCompleter", + # Fuzzy + "FuzzyCompleter", + "FuzzyWordCompleter", + # Nested. + "NestedCompleter", + # Word completer. + "WordCompleter", + # Deduplicate + "DeduplicateCompleter", +] diff --git a/src/prompt_toolkit/completion/base.py b/src/prompt_toolkit/completion/base.py new file mode 100644 index 0000000..04a712d --- /dev/null +++ b/src/prompt_toolkit/completion/base.py @@ -0,0 +1,451 @@ +""" +""" +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from typing import AsyncGenerator, Callable, Iterable, Sequence + +from prompt_toolkit.document import Document +from prompt_toolkit.eventloop import aclosing, generator_to_async_generator +from prompt_toolkit.filters import FilterOrBool, to_filter +from prompt_toolkit.formatted_text import AnyFormattedText, StyleAndTextTuples + +__all__ = [ + "Completion", + "Completer", + "ThreadedCompleter", + "DummyCompleter", + "DynamicCompleter", + "CompleteEvent", + "ConditionalCompleter", + "merge_completers", + "get_common_complete_suffix", +] + + +class Completion: + """ + :param text: The new string that will be inserted into the document. + :param start_position: Position relative to the cursor_position where the + new text will start. The text will be inserted between the + start_position and the original cursor position. + :param display: (optional string or formatted text) If the completion has + to be displayed differently in the completion menu. + :param display_meta: (Optional string or formatted text) Meta information + about the completion, e.g. the path or source where it's coming from. + This can also be a callable that returns a string. + :param style: Style string. + :param selected_style: Style string, used for a selected completion. + This can override the `style` parameter. + """ + + def __init__( + self, + text: str, + start_position: int = 0, + display: AnyFormattedText | None = None, + display_meta: AnyFormattedText | None = None, + style: str = "", + selected_style: str = "", + ) -> None: + from prompt_toolkit.formatted_text import to_formatted_text + + self.text = text + self.start_position = start_position + self._display_meta = display_meta + + if display is None: + display = text + + self.display = to_formatted_text(display) + + self.style = style + self.selected_style = selected_style + + assert self.start_position <= 0 + + def __repr__(self) -> str: + if isinstance(self.display, str) and self.display == self.text: + return "{}(text={!r}, start_position={!r})".format( + self.__class__.__name__, + self.text, + self.start_position, + ) + else: + return "{}(text={!r}, start_position={!r}, display={!r})".format( + self.__class__.__name__, + self.text, + self.start_position, + self.display, + ) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Completion): + return False + return ( + self.text == other.text + and self.start_position == other.start_position + and self.display == other.display + and self._display_meta == other._display_meta + ) + + def __hash__(self) -> int: + return hash((self.text, self.start_position, self.display, self._display_meta)) + + @property + def display_text(self) -> str: + "The 'display' field as plain text." + from prompt_toolkit.formatted_text import fragment_list_to_text + + return fragment_list_to_text(self.display) + + @property + def display_meta(self) -> StyleAndTextTuples: + "Return meta-text. (This is lazy when using a callable)." + from prompt_toolkit.formatted_text import to_formatted_text + + return to_formatted_text(self._display_meta or "") + + @property + def display_meta_text(self) -> str: + "The 'meta' field as plain text." + from prompt_toolkit.formatted_text import fragment_list_to_text + + return fragment_list_to_text(self.display_meta) + + def new_completion_from_position(self, position: int) -> Completion: + """ + (Only for internal use!) + Get a new completion by splitting this one. Used by `Application` when + it needs to have a list of new completions after inserting the common + prefix. + """ + assert position - self.start_position >= 0 + + return Completion( + text=self.text[position - self.start_position :], + display=self.display, + display_meta=self._display_meta, + ) + + +class CompleteEvent: + """ + Event that called the completer. + + :param text_inserted: When True, it means that completions are requested + because of a text insert. (`Buffer.complete_while_typing`.) + :param completion_requested: When True, it means that the user explicitly + pressed the `Tab` key in order to view the completions. + + These two flags can be used for instance to implement a completer that + shows some completions when ``Tab`` has been pressed, but not + automatically when the user presses a space. (Because of + `complete_while_typing`.) + """ + + def __init__( + self, text_inserted: bool = False, completion_requested: bool = False + ) -> None: + assert not (text_inserted and completion_requested) + + #: Automatic completion while typing. + self.text_inserted = text_inserted + + #: Used explicitly requested completion by pressing 'tab'. + self.completion_requested = completion_requested + + def __repr__(self) -> str: + return "{}(text_inserted={!r}, completion_requested={!r})".format( + self.__class__.__name__, + self.text_inserted, + self.completion_requested, + ) + + +class Completer(metaclass=ABCMeta): + """ + Base class for completer implementations. + """ + + @abstractmethod + def get_completions( + self, document: Document, complete_event: CompleteEvent + ) -> Iterable[Completion]: + """ + This should be a generator that yields :class:`.Completion` instances. + + If the generation of completions is something expensive (that takes a + lot of time), consider wrapping this `Completer` class in a + `ThreadedCompleter`. In that case, the completer algorithm runs in a + background thread and completions will be displayed as soon as they + arrive. + + :param document: :class:`~prompt_toolkit.document.Document` instance. + :param complete_event: :class:`.CompleteEvent` instance. + """ + while False: + yield + + async def get_completions_async( + self, document: Document, complete_event: CompleteEvent + ) -> AsyncGenerator[Completion, None]: + """ + Asynchronous generator for completions. (Probably, you won't have to + override this.) + + Asynchronous generator of :class:`.Completion` objects. + """ + for item in self.get_completions(document, complete_event): + yield item + + +class ThreadedCompleter(Completer): + """ + Wrapper that runs the `get_completions` generator in a thread. + + (Use this to prevent the user interface from becoming unresponsive if the + generation of completions takes too much time.) + + The completions will be displayed as soon as they are produced. The user + can already select a completion, even if not all completions are displayed. + """ + + def __init__(self, completer: Completer) -> None: + self.completer = completer + + def get_completions( + self, document: Document, complete_event: CompleteEvent + ) -> Iterable[Completion]: + return self.completer.get_completions(document, complete_event) + + async def get_completions_async( + self, document: Document, complete_event: CompleteEvent + ) -> AsyncGenerator[Completion, None]: + """ + Asynchronous generator of completions. + """ + # NOTE: Right now, we are consuming the `get_completions` generator in + # a synchronous background thread, then passing the results one + # at a time over a queue, and consuming this queue in the main + # thread (that's what `generator_to_async_generator` does). That + # means that if the completer is *very* slow, we'll be showing + # completions in the UI once they are computed. + + # It's very tempting to replace this implementation with the + # commented code below for several reasons: + + # - `generator_to_async_generator` is not perfect and hard to get + # right. It's a lot of complexity for little gain. The + # implementation needs a huge buffer for it to be efficient + # when there are many completions (like 50k+). + # - Normally, a completer is supposed to be fast, users can have + # "complete while typing" enabled, and want to see the + # completions within a second. Handling one completion at a + # time, and rendering once we get it here doesn't make any + # sense if this is quick anyway. + # - Completers like `FuzzyCompleter` prepare all completions + # anyway so that they can be sorted by accuracy before they are + # yielded. At the point that we start yielding completions + # here, we already have all completions. + # - The `Buffer` class has complex logic to invalidate the UI + # while it is consuming the completions. We don't want to + # invalidate the UI for every completion (if there are many), + # but we want to do it often enough so that completions are + # being displayed while they are produced. + + # We keep the current behavior mainly for backward-compatibility. + # Similarly, it would be better for this function to not return + # an async generator, but simply be a coroutine that returns a + # list of `Completion` objects, containing all completions at + # once. + + # Note that this argument doesn't mean we shouldn't use + # `ThreadedCompleter`. It still makes sense to produce + # completions in a background thread, because we don't want to + # freeze the UI while the user is typing. But sending the + # completions one at a time to the UI maybe isn't worth it. + + # def get_all_in_thread() -> List[Completion]: + # return list(self.get_completions(document, complete_event)) + + # completions = await get_running_loop().run_in_executor(None, get_all_in_thread) + # for completion in completions: + # yield completion + + async with aclosing( + generator_to_async_generator( + lambda: self.completer.get_completions(document, complete_event) + ) + ) as async_generator: + async for completion in async_generator: + yield completion + + def __repr__(self) -> str: + return f"ThreadedCompleter({self.completer!r})" + + +class DummyCompleter(Completer): + """ + A completer that doesn't return any completion. + """ + + def get_completions( + self, document: Document, complete_event: CompleteEvent + ) -> Iterable[Completion]: + return [] + + def __repr__(self) -> str: + return "DummyCompleter()" + + +class DynamicCompleter(Completer): + """ + Completer class that can dynamically returns any Completer. + + :param get_completer: Callable that returns a :class:`.Completer` instance. + """ + + def __init__(self, get_completer: Callable[[], Completer | None]) -> None: + self.get_completer = get_completer + + def get_completions( + self, document: Document, complete_event: CompleteEvent + ) -> Iterable[Completion]: + completer = self.get_completer() or DummyCompleter() + return completer.get_completions(document, complete_event) + + async def get_completions_async( + self, document: Document, complete_event: CompleteEvent + ) -> AsyncGenerator[Completion, None]: + completer = self.get_completer() or DummyCompleter() + + async for completion in completer.get_completions_async( + document, complete_event + ): + yield completion + + def __repr__(self) -> str: + return f"DynamicCompleter({self.get_completer!r} -> {self.get_completer()!r})" + + +class ConditionalCompleter(Completer): + """ + Wrapper around any other completer that will enable/disable the completions + depending on whether the received condition is satisfied. + + :param completer: :class:`.Completer` instance. + :param filter: :class:`.Filter` instance. + """ + + def __init__(self, completer: Completer, filter: FilterOrBool) -> None: + self.completer = completer + self.filter = to_filter(filter) + + def __repr__(self) -> str: + return f"ConditionalCompleter({self.completer!r}, filter={self.filter!r})" + + def get_completions( + self, document: Document, complete_event: CompleteEvent + ) -> Iterable[Completion]: + # Get all completions in a blocking way. + if self.filter(): + yield from self.completer.get_completions(document, complete_event) + + async def get_completions_async( + self, document: Document, complete_event: CompleteEvent + ) -> AsyncGenerator[Completion, None]: + # Get all completions in a non-blocking way. + if self.filter(): + async with aclosing( + self.completer.get_completions_async(document, complete_event) + ) as async_generator: + async for item in async_generator: + yield item + + +class _MergedCompleter(Completer): + """ + Combine several completers into one. + """ + + def __init__(self, completers: Sequence[Completer]) -> None: + self.completers = completers + + def get_completions( + self, document: Document, complete_event: CompleteEvent + ) -> Iterable[Completion]: + # Get all completions from the other completers in a blocking way. + for completer in self.completers: + yield from completer.get_completions(document, complete_event) + + async def get_completions_async( + self, document: Document, complete_event: CompleteEvent + ) -> AsyncGenerator[Completion, None]: + # Get all completions from the other completers in a non-blocking way. + for completer in self.completers: + async with aclosing( + completer.get_completions_async(document, complete_event) + ) as async_generator: + async for item in async_generator: + yield item + + +def merge_completers( + completers: Sequence[Completer], deduplicate: bool = False +) -> Completer: + """ + Combine several completers into one. + + :param deduplicate: If `True`, wrap the result in a `DeduplicateCompleter` + so that completions that would result in the same text will be + deduplicated. + """ + if deduplicate: + from .deduplicate import DeduplicateCompleter + + return DeduplicateCompleter(_MergedCompleter(completers)) + + return _MergedCompleter(completers) + + +def get_common_complete_suffix( + document: Document, completions: Sequence[Completion] +) -> str: + """ + Return the common prefix for all completions. + """ + + # Take only completions that don't change the text before the cursor. + def doesnt_change_before_cursor(completion: Completion) -> bool: + end = completion.text[: -completion.start_position] + return document.text_before_cursor.endswith(end) + + completions2 = [c for c in completions if doesnt_change_before_cursor(c)] + + # When there is at least one completion that changes the text before the + # cursor, don't return any common part. + if len(completions2) != len(completions): + return "" + + # Return the common prefix. + def get_suffix(completion: Completion) -> str: + return completion.text[-completion.start_position :] + + return _commonprefix([get_suffix(c) for c in completions2]) + + +def _commonprefix(strings: Iterable[str]) -> str: + # Similar to os.path.commonprefix + if not strings: + return "" + + else: + s1 = min(strings) + s2 = max(strings) + + for i, c in enumerate(s1): + if c != s2[i]: + return s1[:i] + + return s1 diff --git a/src/prompt_toolkit/completion/deduplicate.py b/src/prompt_toolkit/completion/deduplicate.py new file mode 100644 index 0000000..c3d5256 --- /dev/null +++ b/src/prompt_toolkit/completion/deduplicate.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from typing import Iterable + +from prompt_toolkit.document import Document + +from .base import CompleteEvent, Completer, Completion + +__all__ = ["DeduplicateCompleter"] + + +class DeduplicateCompleter(Completer): + """ + Wrapper around a completer that removes duplicates. Only the first unique + completions are kept. + + Completions are considered to be a duplicate if they result in the same + document text when they would be applied. + """ + + def __init__(self, completer: Completer) -> None: + self.completer = completer + + def get_completions( + self, document: Document, complete_event: CompleteEvent + ) -> Iterable[Completion]: + # Keep track of the document strings we'd get after applying any completion. + found_so_far: set[str] = set() + + for completion in self.completer.get_completions(document, complete_event): + text_if_applied = ( + document.text[: document.cursor_position + completion.start_position] + + completion.text + + document.text[document.cursor_position :] + ) + + if text_if_applied == document.text: + # Don't include completions that don't have any effect at all. + continue + + if text_if_applied in found_so_far: + continue + + found_so_far.add(text_if_applied) + yield completion diff --git a/src/prompt_toolkit/completion/filesystem.py b/src/prompt_toolkit/completion/filesystem.py new file mode 100644 index 0000000..8e7f87e --- /dev/null +++ b/src/prompt_toolkit/completion/filesystem.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import os +from typing import Callable, Iterable + +from prompt_toolkit.completion import CompleteEvent, Completer, Completion +from prompt_toolkit.document import Document + +__all__ = [ + "PathCompleter", + "ExecutableCompleter", +] + + +class PathCompleter(Completer): + """ + Complete for Path variables. + + :param get_paths: Callable which returns a list of directories to look into + when the user enters a relative path. + :param file_filter: Callable which takes a filename and returns whether + this file should show up in the completion. ``None`` + when no filtering has to be done. + :param min_input_len: Don't do autocompletion when the input string is shorter. + """ + + def __init__( + self, + only_directories: bool = False, + get_paths: Callable[[], list[str]] | None = None, + file_filter: Callable[[str], bool] | None = None, + min_input_len: int = 0, + expanduser: bool = False, + ) -> None: + self.only_directories = only_directories + self.get_paths = get_paths or (lambda: ["."]) + self.file_filter = file_filter or (lambda _: True) + self.min_input_len = min_input_len + self.expanduser = expanduser + + def get_completions( + self, document: Document, complete_event: CompleteEvent + ) -> Iterable[Completion]: + text = document.text_before_cursor + + # Complete only when we have at least the minimal input length, + # otherwise, we can too many results and autocompletion will become too + # heavy. + if len(text) < self.min_input_len: + return + + try: + # Do tilde expansion. + if self.expanduser: + text = os.path.expanduser(text) + + # Directories where to look. + dirname = os.path.dirname(text) + if dirname: + directories = [ + os.path.dirname(os.path.join(p, text)) for p in self.get_paths() + ] + else: + directories = self.get_paths() + + # Start of current file. + prefix = os.path.basename(text) + + # Get all filenames. + filenames = [] + for directory in directories: + # Look for matches in this directory. + if os.path.isdir(directory): + for filename in os.listdir(directory): + if filename.startswith(prefix): + filenames.append((directory, filename)) + + # Sort + filenames = sorted(filenames, key=lambda k: k[1]) + + # Yield them. + for directory, filename in filenames: + completion = filename[len(prefix) :] + full_name = os.path.join(directory, filename) + + if os.path.isdir(full_name): + # For directories, add a slash to the filename. + # (We don't add them to the `completion`. Users can type it + # to trigger the autocompletion themselves.) + filename += "/" + elif self.only_directories: + continue + + if not self.file_filter(full_name): + continue + + yield Completion( + text=completion, + start_position=0, + display=filename, + ) + except OSError: + pass + + +class ExecutableCompleter(PathCompleter): + """ + Complete only executable files in the current path. + """ + + def __init__(self) -> None: + super().__init__( + only_directories=False, + min_input_len=1, + get_paths=lambda: os.environ.get("PATH", "").split(os.pathsep), + file_filter=lambda name: os.access(name, os.X_OK), + expanduser=True, + ) diff --git a/src/prompt_toolkit/completion/fuzzy_completer.py b/src/prompt_toolkit/completion/fuzzy_completer.py new file mode 100644 index 0000000..25ea892 --- /dev/null +++ b/src/prompt_toolkit/completion/fuzzy_completer.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import re +from typing import Callable, Iterable, NamedTuple + +from prompt_toolkit.document import Document +from prompt_toolkit.filters import FilterOrBool, to_filter +from prompt_toolkit.formatted_text import AnyFormattedText, StyleAndTextTuples + +from .base import CompleteEvent, Completer, Completion +from .word_completer import WordCompleter + +__all__ = [ + "FuzzyCompleter", + "FuzzyWordCompleter", +] + + +class FuzzyCompleter(Completer): + """ + Fuzzy completion. + This wraps any other completer and turns it into a fuzzy completer. + + If the list of words is: ["leopard" , "gorilla", "dinosaur", "cat", "bee"] + Then trying to complete "oar" would yield "leopard" and "dinosaur", but not + the others, because they match the regular expression 'o.*a.*r'. + Similar, in another application "djm" could expand to "django_migrations". + + The results are sorted by relevance, which is defined as the start position + and the length of the match. + + Notice that this is not really a tool to work around spelling mistakes, + like what would be possible with difflib. The purpose is rather to have a + quicker or more intuitive way to filter the given completions, especially + when many completions have a common prefix. + + Fuzzy algorithm is based on this post: + https://blog.amjith.com/fuzzyfinder-in-10-lines-of-python + + :param completer: A :class:`~.Completer` instance. + :param WORD: When True, use WORD characters. + :param pattern: Regex pattern which selects the characters before the + cursor that are considered for the fuzzy matching. + :param enable_fuzzy: (bool or `Filter`) Enabled the fuzzy behavior. For + easily turning fuzzyness on or off according to a certain condition. + """ + + def __init__( + self, + completer: Completer, + WORD: bool = False, + pattern: str | None = None, + enable_fuzzy: FilterOrBool = True, + ) -> None: + assert pattern is None or pattern.startswith("^") + + self.completer = completer + self.pattern = pattern + self.WORD = WORD + self.pattern = pattern + self.enable_fuzzy = to_filter(enable_fuzzy) + + def get_completions( + self, document: Document, complete_event: CompleteEvent + ) -> Iterable[Completion]: + if self.enable_fuzzy(): + return self._get_fuzzy_completions(document, complete_event) + else: + return self.completer.get_completions(document, complete_event) + + def _get_pattern(self) -> str: + if self.pattern: + return self.pattern + if self.WORD: + return r"[^\s]+" + return "^[a-zA-Z0-9_]*" + + def _get_fuzzy_completions( + self, document: Document, complete_event: CompleteEvent + ) -> Iterable[Completion]: + word_before_cursor = document.get_word_before_cursor( + pattern=re.compile(self._get_pattern()) + ) + + # Get completions + document2 = Document( + text=document.text[: document.cursor_position - len(word_before_cursor)], + cursor_position=document.cursor_position - len(word_before_cursor), + ) + + inner_completions = list( + self.completer.get_completions(document2, complete_event) + ) + + fuzzy_matches: list[_FuzzyMatch] = [] + + if word_before_cursor == "": + # If word before the cursor is an empty string, consider all + # completions, without filtering everything with an empty regex + # pattern. + fuzzy_matches = [_FuzzyMatch(0, 0, compl) for compl in inner_completions] + else: + pat = ".*?".join(map(re.escape, word_before_cursor)) + pat = f"(?=({pat}))" # lookahead regex to manage overlapping matches + regex = re.compile(pat, re.IGNORECASE) + for compl in inner_completions: + matches = list(regex.finditer(compl.text)) + if matches: + # Prefer the match, closest to the left, then shortest. + best = min(matches, key=lambda m: (m.start(), len(m.group(1)))) + fuzzy_matches.append( + _FuzzyMatch(len(best.group(1)), best.start(), compl) + ) + + def sort_key(fuzzy_match: _FuzzyMatch) -> tuple[int, int]: + "Sort by start position, then by the length of the match." + return fuzzy_match.start_pos, fuzzy_match.match_length + + fuzzy_matches = sorted(fuzzy_matches, key=sort_key) + + for match in fuzzy_matches: + # Include these completions, but set the correct `display` + # attribute and `start_position`. + yield Completion( + text=match.completion.text, + start_position=match.completion.start_position + - len(word_before_cursor), + # We access to private `_display_meta` attribute, because that one is lazy. + display_meta=match.completion._display_meta, + display=self._get_display(match, word_before_cursor), + style=match.completion.style, + ) + + def _get_display( + self, fuzzy_match: _FuzzyMatch, word_before_cursor: str + ) -> AnyFormattedText: + """ + Generate formatted text for the display label. + """ + + def get_display() -> AnyFormattedText: + m = fuzzy_match + word = m.completion.text + + if m.match_length == 0: + # No highlighting when we have zero length matches (no input text). + # In this case, use the original display text (which can include + # additional styling or characters). + return m.completion.display + + result: StyleAndTextTuples = [] + + # Text before match. + result.append(("class:fuzzymatch.outside", word[: m.start_pos])) + + # The match itself. + characters = list(word_before_cursor) + + for c in word[m.start_pos : m.start_pos + m.match_length]: + classname = "class:fuzzymatch.inside" + if characters and c.lower() == characters[0].lower(): + classname += ".character" + del characters[0] + + result.append((classname, c)) + + # Text after match. + result.append( + ("class:fuzzymatch.outside", word[m.start_pos + m.match_length :]) + ) + + return result + + return get_display() + + +class FuzzyWordCompleter(Completer): + """ + Fuzzy completion on a list of words. + + (This is basically a `WordCompleter` wrapped in a `FuzzyCompleter`.) + + :param words: List of words or callable that returns a list of words. + :param meta_dict: Optional dict mapping words to their meta-information. + :param WORD: When True, use WORD characters. + """ + + def __init__( + self, + words: list[str] | Callable[[], list[str]], + meta_dict: dict[str, str] | None = None, + WORD: bool = False, + ) -> None: + self.words = words + self.meta_dict = meta_dict or {} + self.WORD = WORD + + self.word_completer = WordCompleter( + words=self.words, WORD=self.WORD, meta_dict=self.meta_dict + ) + + self.fuzzy_completer = FuzzyCompleter(self.word_completer, WORD=self.WORD) + + def get_completions( + self, document: Document, complete_event: CompleteEvent + ) -> Iterable[Completion]: + return self.fuzzy_completer.get_completions(document, complete_event) + + +class _FuzzyMatch(NamedTuple): + match_length: int + start_pos: int + completion: Completion diff --git a/src/prompt_toolkit/completion/nested.py b/src/prompt_toolkit/completion/nested.py new file mode 100644 index 0000000..a1d211a --- /dev/null +++ b/src/prompt_toolkit/completion/nested.py @@ -0,0 +1,108 @@ +""" +Nestedcompleter for completion of hierarchical data structures. +""" +from __future__ import annotations + +from typing import Any, Iterable, Mapping, Set, Union + +from prompt_toolkit.completion import CompleteEvent, Completer, Completion +from prompt_toolkit.completion.word_completer import WordCompleter +from prompt_toolkit.document import Document + +__all__ = ["NestedCompleter"] + +# NestedDict = Mapping[str, Union['NestedDict', Set[str], None, Completer]] +NestedDict = Mapping[str, Union[Any, Set[str], None, Completer]] + + +class NestedCompleter(Completer): + """ + Completer which wraps around several other completers, and calls any the + one that corresponds with the first word of the input. + + By combining multiple `NestedCompleter` instances, we can achieve multiple + hierarchical levels of autocompletion. This is useful when `WordCompleter` + is not sufficient. + + If you need multiple levels, check out the `from_nested_dict` classmethod. + """ + + def __init__( + self, options: dict[str, Completer | None], ignore_case: bool = True + ) -> None: + self.options = options + self.ignore_case = ignore_case + + def __repr__(self) -> str: + return f"NestedCompleter({self.options!r}, ignore_case={self.ignore_case!r})" + + @classmethod + def from_nested_dict(cls, data: NestedDict) -> NestedCompleter: + """ + Create a `NestedCompleter`, starting from a nested dictionary data + structure, like this: + + .. code:: + + data = { + 'show': { + 'version': None, + 'interfaces': None, + 'clock': None, + 'ip': {'interface': {'brief'}} + }, + 'exit': None + 'enable': None + } + + The value should be `None` if there is no further completion at some + point. If all values in the dictionary are None, it is also possible to + use a set instead. + + Values in this data structure can be a completers as well. + """ + options: dict[str, Completer | None] = {} + for key, value in data.items(): + if isinstance(value, Completer): + options[key] = value + elif isinstance(value, dict): + options[key] = cls.from_nested_dict(value) + elif isinstance(value, set): + options[key] = cls.from_nested_dict({item: None for item in value}) + else: + assert value is None + options[key] = None + + return cls(options) + + def get_completions( + self, document: Document, complete_event: CompleteEvent + ) -> Iterable[Completion]: + # Split document. + text = document.text_before_cursor.lstrip() + stripped_len = len(document.text_before_cursor) - len(text) + + # If there is a space, check for the first term, and use a + # subcompleter. + if " " in text: + first_term = text.split()[0] + completer = self.options.get(first_term) + + # If we have a sub completer, use this for the completions. + if completer is not None: + remaining_text = text[len(first_term) :].lstrip() + move_cursor = len(text) - len(remaining_text) + stripped_len + + new_document = Document( + remaining_text, + cursor_position=document.cursor_position - move_cursor, + ) + + yield from completer.get_completions(new_document, complete_event) + + # No space in the input: behave exactly like `WordCompleter`. + else: + completer = WordCompleter( + list(self.options.keys()), ignore_case=self.ignore_case + ) + yield from completer.get_completions(document, complete_event) diff --git a/src/prompt_toolkit/completion/word_completer.py b/src/prompt_toolkit/completion/word_completer.py new file mode 100644 index 0000000..6ef4031 --- /dev/null +++ b/src/prompt_toolkit/completion/word_completer.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from typing import Callable, Iterable, Mapping, Pattern + +from prompt_toolkit.completion import CompleteEvent, Completer, Completion +from prompt_toolkit.document import Document +from prompt_toolkit.formatted_text import AnyFormattedText + +__all__ = [ + "WordCompleter", +] + + +class WordCompleter(Completer): + """ + Simple autocompletion on a list of words. + + :param words: List of words or callable that returns a list of words. + :param ignore_case: If True, case-insensitive completion. + :param meta_dict: Optional dict mapping words to their meta-text. (This + should map strings to strings or formatted text.) + :param WORD: When True, use WORD characters. + :param sentence: When True, don't complete by comparing the word before the + cursor, but by comparing all the text before the cursor. In this case, + the list of words is just a list of strings, where each string can + contain spaces. (Can not be used together with the WORD option.) + :param match_middle: When True, match not only the start, but also in the + middle of the word. + :param pattern: Optional compiled regex for finding the word before + the cursor to complete. When given, use this regex pattern instead of + default one (see document._FIND_WORD_RE) + """ + + def __init__( + self, + words: list[str] | Callable[[], list[str]], + ignore_case: bool = False, + display_dict: Mapping[str, AnyFormattedText] | None = None, + meta_dict: Mapping[str, AnyFormattedText] | None = None, + WORD: bool = False, + sentence: bool = False, + match_middle: bool = False, + pattern: Pattern[str] | None = None, + ) -> None: + assert not (WORD and sentence) + + self.words = words + self.ignore_case = ignore_case + self.display_dict = display_dict or {} + self.meta_dict = meta_dict or {} + self.WORD = WORD + self.sentence = sentence + self.match_middle = match_middle + self.pattern = pattern + + def get_completions( + self, document: Document, complete_event: CompleteEvent + ) -> Iterable[Completion]: + # Get list of words. + words = self.words + if callable(words): + words = words() + + # Get word/text before cursor. + if self.sentence: + word_before_cursor = document.text_before_cursor + else: + word_before_cursor = document.get_word_before_cursor( + WORD=self.WORD, pattern=self.pattern + ) + + if self.ignore_case: + word_before_cursor = word_before_cursor.lower() + + def word_matches(word: str) -> bool: + """True when the word before the cursor matches.""" + if self.ignore_case: + word = word.lower() + + if self.match_middle: + return word_before_cursor in word + else: + return word.startswith(word_before_cursor) + + for a in words: + if word_matches(a): + display = self.display_dict.get(a, a) + display_meta = self.meta_dict.get(a, "") + yield Completion( + text=a, + start_position=-len(word_before_cursor), + display=display, + display_meta=display_meta, + ) diff --git a/src/prompt_toolkit/contrib/__init__.py b/src/prompt_toolkit/contrib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/prompt_toolkit/contrib/completers/__init__.py b/src/prompt_toolkit/contrib/completers/__init__.py new file mode 100644 index 0000000..172fe6f --- /dev/null +++ b/src/prompt_toolkit/contrib/completers/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .system import SystemCompleter + +__all__ = ["SystemCompleter"] diff --git a/src/prompt_toolkit/contrib/completers/system.py b/src/prompt_toolkit/contrib/completers/system.py new file mode 100644 index 0000000..5d990e5 --- /dev/null +++ b/src/prompt_toolkit/contrib/completers/system.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from prompt_toolkit.completion.filesystem import ExecutableCompleter, PathCompleter +from prompt_toolkit.contrib.regular_languages.compiler import compile +from prompt_toolkit.contrib.regular_languages.completion import GrammarCompleter + +__all__ = [ + "SystemCompleter", +] + + +class SystemCompleter(GrammarCompleter): + """ + Completer for system commands. + """ + + def __init__(self) -> None: + # Compile grammar. + g = compile( + r""" + # First we have an executable. + (?P<executable>[^\s]+) + + # Ignore literals in between. + ( + \s+ + ("[^"]*" | '[^']*' | [^'"]+ ) + )* + + \s+ + + # Filename as parameters. + ( + (?P<filename>[^\s]+) | + "(?P<double_quoted_filename>[^\s]+)" | + '(?P<single_quoted_filename>[^\s]+)' + ) + """, + escape_funcs={ + "double_quoted_filename": (lambda string: string.replace('"', '\\"')), + "single_quoted_filename": (lambda string: string.replace("'", "\\'")), + }, + unescape_funcs={ + "double_quoted_filename": ( + lambda string: string.replace('\\"', '"') + ), # XXX: not entirely correct. + "single_quoted_filename": (lambda string: string.replace("\\'", "'")), + }, + ) + + # Create GrammarCompleter + super().__init__( + g, + { + "executable": ExecutableCompleter(), + "filename": PathCompleter(only_directories=False, expanduser=True), + "double_quoted_filename": PathCompleter( + only_directories=False, expanduser=True + ), + "single_quoted_filename": PathCompleter( + only_directories=False, expanduser=True + ), + }, + ) diff --git a/src/prompt_toolkit/contrib/regular_languages/__init__.py b/src/prompt_toolkit/contrib/regular_languages/__init__.py new file mode 100644 index 0000000..c947fd5 --- /dev/null +++ b/src/prompt_toolkit/contrib/regular_languages/__init__.py @@ -0,0 +1,79 @@ +r""" +Tool for expressing the grammar of an input as a regular language. +================================================================== + +The grammar for the input of many simple command line interfaces can be +expressed by a regular language. Examples are PDB (the Python debugger); a +simple (bash-like) shell with "pwd", "cd", "cat" and "ls" commands; arguments +that you can pass to an executable; etc. It is possible to use regular +expressions for validation and parsing of such a grammar. (More about regular +languages: http://en.wikipedia.org/wiki/Regular_language) + +Example +------- + +Let's take the pwd/cd/cat/ls example. We want to have a shell that accepts +these three commands. "cd" is followed by a quoted directory name and "cat" is +followed by a quoted file name. (We allow quotes inside the filename when +they're escaped with a backslash.) We could define the grammar using the +following regular expression:: + + grammar = \s* ( + pwd | + ls | + (cd \s+ " ([^"]|\.)+ ") | + (cat \s+ " ([^"]|\.)+ ") + ) \s* + + +What can we do with this grammar? +--------------------------------- + +- Syntax highlighting: We could use this for instance to give file names + different color. +- Parse the result: .. We can extract the file names and commands by using a + regular expression with named groups. +- Input validation: .. Don't accept anything that does not match this grammar. + When combined with a parser, we can also recursively do + filename validation (and accept only existing files.) +- Autocompletion: .... Each part of the grammar can have its own autocompleter. + "cat" has to be completed using file names, while "cd" + has to be completed using directory names. + +How does it work? +----------------- + +As a user of this library, you have to define the grammar of the input as a +regular expression. The parts of this grammar where autocompletion, validation +or any other processing is required need to be marked using a regex named +group. Like ``(?P<varname>...)`` for instance. + +When the input is processed for validation (for instance), the regex will +execute, the named group is captured, and the validator associated with this +named group will test the captured string. + +There is one tricky bit: + + Often we operate on incomplete input (this is by definition the case for + autocompletion) and we have to decide for the cursor position in which + possible state the grammar it could be and in which way variables could be + matched up to that point. + +To solve this problem, the compiler takes the original regular expression and +translates it into a set of other regular expressions which each match certain +prefixes of the original regular expression. We generate one prefix regular +expression for every named variable (with this variable being the end of that +expression). + + +TODO: some examples of: + - How to create a highlighter from this grammar. + - How to create a validator from this grammar. + - How to create an autocompleter from this grammar. + - How to create a parser from this grammar. +""" +from __future__ import annotations + +from .compiler import compile + +__all__ = ["compile"] diff --git a/src/prompt_toolkit/contrib/regular_languages/compiler.py b/src/prompt_toolkit/contrib/regular_languages/compiler.py new file mode 100644 index 0000000..474f6cf --- /dev/null +++ b/src/prompt_toolkit/contrib/regular_languages/compiler.py @@ -0,0 +1,571 @@ +r""" +Compiler for a regular grammar. + +Example usage:: + + # Create and compile grammar. + p = compile('add \s+ (?P<var1>[^\s]+) \s+ (?P<var2>[^\s]+)') + + # Match input string. + m = p.match('add 23 432') + + # Get variables. + m.variables().get('var1') # Returns "23" + m.variables().get('var2') # Returns "432" + + +Partial matches are possible:: + + # Create and compile grammar. + p = compile(''' + # Operators with two arguments. + ((?P<operator1>[^\s]+) \s+ (?P<var1>[^\s]+) \s+ (?P<var2>[^\s]+)) | + + # Operators with only one arguments. + ((?P<operator2>[^\s]+) \s+ (?P<var1>[^\s]+)) + ''') + + # Match partial input string. + m = p.match_prefix('add 23') + + # Get variables. (Notice that both operator1 and operator2 contain the + # value "add".) This is because our input is incomplete, and we don't know + # yet in which rule of the regex we we'll end up. It could also be that + # `operator1` and `operator2` have a different autocompleter and we want to + # call all possible autocompleters that would result in valid input.) + m.variables().get('var1') # Returns "23" + m.variables().get('operator1') # Returns "add" + m.variables().get('operator2') # Returns "add" + +""" +from __future__ import annotations + +import re +from typing import Callable, Dict, Iterable, Iterator, Pattern +from typing import Match as RegexMatch + +from .regex_parser import ( + AnyNode, + Lookahead, + Node, + NodeSequence, + Regex, + Repeat, + Variable, + parse_regex, + tokenize_regex, +) + +__all__ = [ + "compile", +] + + +# Name of the named group in the regex, matching trailing input. +# (Trailing input is when the input contains characters after the end of the +# expression has been matched.) +_INVALID_TRAILING_INPUT = "invalid_trailing" + +EscapeFuncDict = Dict[str, Callable[[str], str]] + + +class _CompiledGrammar: + """ + Compiles a grammar. This will take the parse tree of a regular expression + and compile the grammar. + + :param root_node: :class~`.regex_parser.Node` instance. + :param escape_funcs: `dict` mapping variable names to escape callables. + :param unescape_funcs: `dict` mapping variable names to unescape callables. + """ + + def __init__( + self, + root_node: Node, + escape_funcs: EscapeFuncDict | None = None, + unescape_funcs: EscapeFuncDict | None = None, + ) -> None: + self.root_node = root_node + self.escape_funcs = escape_funcs or {} + self.unescape_funcs = unescape_funcs or {} + + #: Dictionary that will map the regex names to Node instances. + self._group_names_to_nodes: dict[ + str, str + ] = {} # Maps regex group names to varnames. + counter = [0] + + def create_group_func(node: Variable) -> str: + name = "n%s" % counter[0] + self._group_names_to_nodes[name] = node.varname + counter[0] += 1 + return name + + # Compile regex strings. + self._re_pattern = "^%s$" % self._transform(root_node, create_group_func) + self._re_prefix_patterns = list( + self._transform_prefix(root_node, create_group_func) + ) + + # Compile the regex itself. + flags = re.DOTALL # Note that we don't need re.MULTILINE! (^ and $ + # still represent the start and end of input text.) + self._re = re.compile(self._re_pattern, flags) + self._re_prefix = [re.compile(t, flags) for t in self._re_prefix_patterns] + + # We compile one more set of regexes, similar to `_re_prefix`, but accept any trailing + # input. This will ensure that we can still highlight the input correctly, even when the + # input contains some additional characters at the end that don't match the grammar.) + self._re_prefix_with_trailing_input = [ + re.compile( + r"(?:{})(?P<{}>.*?)$".format(t.rstrip("$"), _INVALID_TRAILING_INPUT), + flags, + ) + for t in self._re_prefix_patterns + ] + + def escape(self, varname: str, value: str) -> str: + """ + Escape `value` to fit in the place of this variable into the grammar. + """ + f = self.escape_funcs.get(varname) + return f(value) if f else value + + def unescape(self, varname: str, value: str) -> str: + """ + Unescape `value`. + """ + f = self.unescape_funcs.get(varname) + return f(value) if f else value + + @classmethod + def _transform( + cls, root_node: Node, create_group_func: Callable[[Variable], str] + ) -> str: + """ + Turn a :class:`Node` object into a regular expression. + + :param root_node: The :class:`Node` instance for which we generate the grammar. + :param create_group_func: A callable which takes a `Node` and returns the next + free name for this node. + """ + + def transform(node: Node) -> str: + # Turn `AnyNode` into an OR. + if isinstance(node, AnyNode): + return "(?:%s)" % "|".join(transform(c) for c in node.children) + + # Concatenate a `NodeSequence` + elif isinstance(node, NodeSequence): + return "".join(transform(c) for c in node.children) + + # For Regex and Lookahead nodes, just insert them literally. + elif isinstance(node, Regex): + return node.regex + + elif isinstance(node, Lookahead): + before = "(?!" if node.negative else "(=" + return before + transform(node.childnode) + ")" + + # A `Variable` wraps the children into a named group. + elif isinstance(node, Variable): + return f"(?P<{create_group_func(node)}>{transform(node.childnode)})" + + # `Repeat`. + elif isinstance(node, Repeat): + if node.max_repeat is None: + if node.min_repeat == 0: + repeat_sign = "*" + elif node.min_repeat == 1: + repeat_sign = "+" + else: + repeat_sign = "{%i,%s}" % ( + node.min_repeat, + ("" if node.max_repeat is None else str(node.max_repeat)), + ) + + return "(?:{}){}{}".format( + transform(node.childnode), + repeat_sign, + ("" if node.greedy else "?"), + ) + else: + raise TypeError(f"Got {node!r}") + + return transform(root_node) + + @classmethod + def _transform_prefix( + cls, root_node: Node, create_group_func: Callable[[Variable], str] + ) -> Iterable[str]: + """ + Yield all the regular expressions matching a prefix of the grammar + defined by the `Node` instance. + + For each `Variable`, one regex pattern will be generated, with this + named group at the end. This is required because a regex engine will + terminate once a match is found. For autocompletion however, we need + the matches for all possible paths, so that we can provide completions + for each `Variable`. + + - So, in the case of an `Any` (`A|B|C)', we generate a pattern for each + clause. This is one for `A`, one for `B` and one for `C`. Unless some + groups don't contain a `Variable`, then these can be merged together. + - In the case of a `NodeSequence` (`ABC`), we generate a pattern for + each prefix that ends with a variable, and one pattern for the whole + sequence. So, that's one for `A`, one for `AB` and one for `ABC`. + + :param root_node: The :class:`Node` instance for which we generate the grammar. + :param create_group_func: A callable which takes a `Node` and returns the next + free name for this node. + """ + + def contains_variable(node: Node) -> bool: + if isinstance(node, Regex): + return False + elif isinstance(node, Variable): + return True + elif isinstance(node, (Lookahead, Repeat)): + return contains_variable(node.childnode) + elif isinstance(node, (NodeSequence, AnyNode)): + return any(contains_variable(child) for child in node.children) + + return False + + def transform(node: Node) -> Iterable[str]: + # Generate separate pattern for all terms that contain variables + # within this OR. Terms that don't contain a variable can be merged + # together in one pattern. + if isinstance(node, AnyNode): + # If we have a definition like: + # (?P<name> .*) | (?P<city> .*) + # Then we want to be able to generate completions for both the + # name as well as the city. We do this by yielding two + # different regular expressions, because the engine won't + # follow multiple paths, if multiple are possible. + children_with_variable = [] + children_without_variable = [] + for c in node.children: + if contains_variable(c): + children_with_variable.append(c) + else: + children_without_variable.append(c) + + for c in children_with_variable: + yield from transform(c) + + # Merge options without variable together. + if children_without_variable: + yield "|".join( + r for c in children_without_variable for r in transform(c) + ) + + # For a sequence, generate a pattern for each prefix that ends with + # a variable + one pattern of the complete sequence. + # (This is because, for autocompletion, we match the text before + # the cursor, and completions are given for the variable that we + # match right before the cursor.) + elif isinstance(node, NodeSequence): + # For all components in the sequence, compute prefix patterns, + # as well as full patterns. + complete = [cls._transform(c, create_group_func) for c in node.children] + prefixes = [list(transform(c)) for c in node.children] + variable_nodes = [contains_variable(c) for c in node.children] + + # If any child is contains a variable, we should yield a + # pattern up to that point, so that we are sure this will be + # matched. + for i in range(len(node.children)): + if variable_nodes[i]: + for c_str in prefixes[i]: + yield "".join(complete[:i]) + c_str + + # If there are non-variable nodes, merge all the prefixes into + # one pattern. If the input is: "[part1] [part2] [part3]", then + # this gets compiled into: + # (complete1 + (complete2 + (complete3 | partial3) | partial2) | partial1 ) + # For nodes that contain a variable, we skip the "|partial" + # part here, because thees are matched with the previous + # patterns. + if not all(variable_nodes): + result = [] + + # Start with complete patterns. + for i in range(len(node.children)): + result.append("(?:") + result.append(complete[i]) + + # Add prefix patterns. + for i in range(len(node.children) - 1, -1, -1): + if variable_nodes[i]: + # No need to yield a prefix for this one, we did + # the variable prefixes earlier. + result.append(")") + else: + result.append("|(?:") + # If this yields multiple, we should yield all combinations. + assert len(prefixes[i]) == 1 + result.append(prefixes[i][0]) + result.append("))") + + yield "".join(result) + + elif isinstance(node, Regex): + yield "(?:%s)?" % node.regex + + elif isinstance(node, Lookahead): + if node.negative: + yield "(?!%s)" % cls._transform(node.childnode, create_group_func) + else: + # Not sure what the correct semantics are in this case. + # (Probably it's not worth implementing this.) + raise Exception("Positive lookahead not yet supported.") + + elif isinstance(node, Variable): + # (Note that we should not append a '?' here. the 'transform' + # method will already recursively do that.) + for c_str in transform(node.childnode): + yield f"(?P<{create_group_func(node)}>{c_str})" + + elif isinstance(node, Repeat): + # If we have a repetition of 8 times. That would mean that the + # current input could have for instance 7 times a complete + # match, followed by a partial match. + prefix = cls._transform(node.childnode, create_group_func) + + if node.max_repeat == 1: + yield from transform(node.childnode) + else: + for c_str in transform(node.childnode): + if node.max_repeat: + repeat_sign = "{,%i}" % (node.max_repeat - 1) + else: + repeat_sign = "*" + yield "(?:{}){}{}{}".format( + prefix, + repeat_sign, + ("" if node.greedy else "?"), + c_str, + ) + + else: + raise TypeError("Got %r" % node) + + for r in transform(root_node): + yield "^(?:%s)$" % r + + def match(self, string: str) -> Match | None: + """ + Match the string with the grammar. + Returns a :class:`Match` instance or `None` when the input doesn't match the grammar. + + :param string: The input string. + """ + m = self._re.match(string) + + if m: + return Match( + string, [(self._re, m)], self._group_names_to_nodes, self.unescape_funcs + ) + return None + + def match_prefix(self, string: str) -> Match | None: + """ + Do a partial match of the string with the grammar. The returned + :class:`Match` instance can contain multiple representations of the + match. This will never return `None`. If it doesn't match at all, the "trailing input" + part will capture all of the input. + + :param string: The input string. + """ + # First try to match using `_re_prefix`. If nothing is found, use the patterns that + # also accept trailing characters. + for patterns in [self._re_prefix, self._re_prefix_with_trailing_input]: + matches = [(r, r.match(string)) for r in patterns] + matches2 = [(r, m) for r, m in matches if m] + + if matches2 != []: + return Match( + string, matches2, self._group_names_to_nodes, self.unescape_funcs + ) + + return None + + +class Match: + """ + :param string: The input string. + :param re_matches: List of (compiled_re_pattern, re_match) tuples. + :param group_names_to_nodes: Dictionary mapping all the re group names to the matching Node instances. + """ + + def __init__( + self, + string: str, + re_matches: list[tuple[Pattern[str], RegexMatch[str]]], + group_names_to_nodes: dict[str, str], + unescape_funcs: dict[str, Callable[[str], str]], + ): + self.string = string + self._re_matches = re_matches + self._group_names_to_nodes = group_names_to_nodes + self._unescape_funcs = unescape_funcs + + def _nodes_to_regs(self) -> list[tuple[str, tuple[int, int]]]: + """ + Return a list of (varname, reg) tuples. + """ + + def get_tuples() -> Iterable[tuple[str, tuple[int, int]]]: + for r, re_match in self._re_matches: + for group_name, group_index in r.groupindex.items(): + if group_name != _INVALID_TRAILING_INPUT: + regs = re_match.regs + reg = regs[group_index] + node = self._group_names_to_nodes[group_name] + yield (node, reg) + + return list(get_tuples()) + + def _nodes_to_values(self) -> list[tuple[str, str, tuple[int, int]]]: + """ + Returns list of (Node, string_value) tuples. + """ + + def is_none(sl: tuple[int, int]) -> bool: + return sl[0] == -1 and sl[1] == -1 + + def get(sl: tuple[int, int]) -> str: + return self.string[sl[0] : sl[1]] + + return [ + (varname, get(slice), slice) + for varname, slice in self._nodes_to_regs() + if not is_none(slice) + ] + + def _unescape(self, varname: str, value: str) -> str: + unwrapper = self._unescape_funcs.get(varname) + return unwrapper(value) if unwrapper else value + + def variables(self) -> Variables: + """ + Returns :class:`Variables` instance. + """ + return Variables( + [(k, self._unescape(k, v), sl) for k, v, sl in self._nodes_to_values()] + ) + + def trailing_input(self) -> MatchVariable | None: + """ + Get the `MatchVariable` instance, representing trailing input, if there is any. + "Trailing input" is input at the end that does not match the grammar anymore, but + when this is removed from the end of the input, the input would be a valid string. + """ + slices: list[tuple[int, int]] = [] + + # Find all regex group for the name _INVALID_TRAILING_INPUT. + for r, re_match in self._re_matches: + for group_name, group_index in r.groupindex.items(): + if group_name == _INVALID_TRAILING_INPUT: + slices.append(re_match.regs[group_index]) + + # Take the smallest part. (Smaller trailing text means that a larger input has + # been matched, so that is better.) + if slices: + slice = (max(i[0] for i in slices), max(i[1] for i in slices)) + value = self.string[slice[0] : slice[1]] + return MatchVariable("<trailing_input>", value, slice) + return None + + def end_nodes(self) -> Iterable[MatchVariable]: + """ + Yields `MatchVariable` instances for all the nodes having their end + position at the end of the input string. + """ + for varname, reg in self._nodes_to_regs(): + # If this part goes until the end of the input string. + if reg[1] == len(self.string): + value = self._unescape(varname, self.string[reg[0] : reg[1]]) + yield MatchVariable(varname, value, (reg[0], reg[1])) + + +class Variables: + def __init__(self, tuples: list[tuple[str, str, tuple[int, int]]]) -> None: + #: List of (varname, value, slice) tuples. + self._tuples = tuples + + def __repr__(self) -> str: + return "{}({})".format( + self.__class__.__name__, + ", ".join(f"{k}={v!r}" for k, v, _ in self._tuples), + ) + + def get(self, key: str, default: str | None = None) -> str | None: + items = self.getall(key) + return items[0] if items else default + + def getall(self, key: str) -> list[str]: + return [v for k, v, _ in self._tuples if k == key] + + def __getitem__(self, key: str) -> str | None: + return self.get(key) + + def __iter__(self) -> Iterator[MatchVariable]: + """ + Yield `MatchVariable` instances. + """ + for varname, value, slice in self._tuples: + yield MatchVariable(varname, value, slice) + + +class MatchVariable: + """ + Represents a match of a variable in the grammar. + + :param varname: (string) Name of the variable. + :param value: (string) Value of this variable. + :param slice: (start, stop) tuple, indicating the position of this variable + in the input string. + """ + + def __init__(self, varname: str, value: str, slice: tuple[int, int]) -> None: + self.varname = varname + self.value = value + self.slice = slice + + self.start = self.slice[0] + self.stop = self.slice[1] + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.varname!r}, {self.value!r})" + + +def compile( + expression: str, + escape_funcs: EscapeFuncDict | None = None, + unescape_funcs: EscapeFuncDict | None = None, +) -> _CompiledGrammar: + """ + Compile grammar (given as regex string), returning a `CompiledGrammar` + instance. + """ + return _compile_from_parse_tree( + parse_regex(tokenize_regex(expression)), + escape_funcs=escape_funcs, + unescape_funcs=unescape_funcs, + ) + + +def _compile_from_parse_tree( + root_node: Node, + escape_funcs: EscapeFuncDict | None = None, + unescape_funcs: EscapeFuncDict | None = None, +) -> _CompiledGrammar: + """ + Compile grammar (given as parse tree), returning a `CompiledGrammar` + instance. + """ + return _CompiledGrammar( + root_node, escape_funcs=escape_funcs, unescape_funcs=unescape_funcs + ) diff --git a/src/prompt_toolkit/contrib/regular_languages/completion.py b/src/prompt_toolkit/contrib/regular_languages/completion.py new file mode 100644 index 0000000..2e353e8 --- /dev/null +++ b/src/prompt_toolkit/contrib/regular_languages/completion.py @@ -0,0 +1,94 @@ +""" +Completer for a regular grammar. +""" +from __future__ import annotations + +from typing import Iterable + +from prompt_toolkit.completion import CompleteEvent, Completer, Completion +from prompt_toolkit.document import Document + +from .compiler import Match, _CompiledGrammar + +__all__ = [ + "GrammarCompleter", +] + + +class GrammarCompleter(Completer): + """ + Completer which can be used for autocompletion according to variables in + the grammar. Each variable can have a different autocompleter. + + :param compiled_grammar: `GrammarCompleter` instance. + :param completers: `dict` mapping variable names of the grammar to the + `Completer` instances to be used for each variable. + """ + + def __init__( + self, compiled_grammar: _CompiledGrammar, completers: dict[str, Completer] + ) -> None: + self.compiled_grammar = compiled_grammar + self.completers = completers + + def get_completions( + self, document: Document, complete_event: CompleteEvent + ) -> Iterable[Completion]: + m = self.compiled_grammar.match_prefix(document.text_before_cursor) + + if m: + completions = self._remove_duplicates( + self._get_completions_for_match(m, complete_event) + ) + + yield from completions + + def _get_completions_for_match( + self, match: Match, complete_event: CompleteEvent + ) -> Iterable[Completion]: + """ + Yield all the possible completions for this input string. + (The completer assumes that the cursor position was at the end of the + input string.) + """ + for match_variable in match.end_nodes(): + varname = match_variable.varname + start = match_variable.start + + completer = self.completers.get(varname) + + if completer: + text = match_variable.value + + # Unwrap text. + unwrapped_text = self.compiled_grammar.unescape(varname, text) + + # Create a document, for the completions API (text/cursor_position) + document = Document(unwrapped_text, len(unwrapped_text)) + + # Call completer + for completion in completer.get_completions(document, complete_event): + new_text = ( + unwrapped_text[: len(text) + completion.start_position] + + completion.text + ) + + # Wrap again. + yield Completion( + text=self.compiled_grammar.escape(varname, new_text), + start_position=start - len(match.string), + display=completion.display, + display_meta=completion.display_meta, + ) + + def _remove_duplicates(self, items: Iterable[Completion]) -> list[Completion]: + """ + Remove duplicates, while keeping the order. + (Sometimes we have duplicates, because the there several matches of the + same grammar, each yielding similar completions.) + """ + result: list[Completion] = [] + for i in items: + if i not in result: + result.append(i) + return result diff --git a/src/prompt_toolkit/contrib/regular_languages/lexer.py b/src/prompt_toolkit/contrib/regular_languages/lexer.py new file mode 100644 index 0000000..b0a4deb --- /dev/null +++ b/src/prompt_toolkit/contrib/regular_languages/lexer.py @@ -0,0 +1,93 @@ +""" +`GrammarLexer` is compatible with other lexers and can be used to highlight +the input using a regular grammar with annotations. +""" +from __future__ import annotations + +from typing import Callable + +from prompt_toolkit.document import Document +from prompt_toolkit.formatted_text.base import StyleAndTextTuples +from prompt_toolkit.formatted_text.utils import split_lines +from prompt_toolkit.lexers import Lexer + +from .compiler import _CompiledGrammar + +__all__ = [ + "GrammarLexer", +] + + +class GrammarLexer(Lexer): + """ + Lexer which can be used for highlighting of fragments according to variables in the grammar. + + (It does not actual lexing of the string, but it exposes an API, compatible + with the Pygments lexer class.) + + :param compiled_grammar: Grammar as returned by the `compile()` function. + :param lexers: Dictionary mapping variable names of the regular grammar to + the lexers that should be used for this part. (This can + call other lexers recursively.) If you wish a part of the + grammar to just get one fragment, use a + `prompt_toolkit.lexers.SimpleLexer`. + """ + + def __init__( + self, + compiled_grammar: _CompiledGrammar, + default_style: str = "", + lexers: dict[str, Lexer] | None = None, + ) -> None: + self.compiled_grammar = compiled_grammar + self.default_style = default_style + self.lexers = lexers or {} + + def _get_text_fragments(self, text: str) -> StyleAndTextTuples: + m = self.compiled_grammar.match_prefix(text) + + if m: + characters: StyleAndTextTuples = [(self.default_style, c) for c in text] + + for v in m.variables(): + # If we have a `Lexer` instance for this part of the input. + # Tokenize recursively and apply tokens. + lexer = self.lexers.get(v.varname) + + if lexer: + document = Document(text[v.start : v.stop]) + lexer_tokens_for_line = lexer.lex_document(document) + text_fragments: StyleAndTextTuples = [] + for i in range(len(document.lines)): + text_fragments.extend(lexer_tokens_for_line(i)) + text_fragments.append(("", "\n")) + if text_fragments: + text_fragments.pop() + + i = v.start + for t, s, *_ in text_fragments: + for c in s: + if characters[i][0] == self.default_style: + characters[i] = (t, characters[i][1]) + i += 1 + + # Highlight trailing input. + trailing_input = m.trailing_input() + if trailing_input: + for i in range(trailing_input.start, trailing_input.stop): + characters[i] = ("class:trailing-input", characters[i][1]) + + return characters + else: + return [("", text)] + + def lex_document(self, document: Document) -> Callable[[int], StyleAndTextTuples]: + lines = list(split_lines(self._get_text_fragments(document.text))) + + def get_line(lineno: int) -> StyleAndTextTuples: + try: + return lines[lineno] + except IndexError: + return [] + + return get_line diff --git a/src/prompt_toolkit/contrib/regular_languages/regex_parser.py b/src/prompt_toolkit/contrib/regular_languages/regex_parser.py new file mode 100644 index 0000000..a365ba8 --- /dev/null +++ b/src/prompt_toolkit/contrib/regular_languages/regex_parser.py @@ -0,0 +1,282 @@ +""" +Parser for parsing a regular expression. +Take a string representing a regular expression and return the root node of its +parse tree. + +usage:: + + root_node = parse_regex('(hello|world)') + +Remarks: +- The regex parser processes multiline, it ignores all whitespace and supports + multiple named groups with the same name and #-style comments. + +Limitations: +- Lookahead is not supported. +""" +from __future__ import annotations + +import re + +__all__ = [ + "Repeat", + "Variable", + "Regex", + "Lookahead", + "tokenize_regex", + "parse_regex", +] + + +class Node: + """ + Base class for all the grammar nodes. + (You don't initialize this one.) + """ + + def __add__(self, other_node: Node) -> NodeSequence: + return NodeSequence([self, other_node]) + + def __or__(self, other_node: Node) -> AnyNode: + return AnyNode([self, other_node]) + + +class AnyNode(Node): + """ + Union operation (OR operation) between several grammars. You don't + initialize this yourself, but it's a result of a "Grammar1 | Grammar2" + operation. + """ + + def __init__(self, children: list[Node]) -> None: + self.children = children + + def __or__(self, other_node: Node) -> AnyNode: + return AnyNode(self.children + [other_node]) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.children!r})" + + +class NodeSequence(Node): + """ + Concatenation operation of several grammars. You don't initialize this + yourself, but it's a result of a "Grammar1 + Grammar2" operation. + """ + + def __init__(self, children: list[Node]) -> None: + self.children = children + + def __add__(self, other_node: Node) -> NodeSequence: + return NodeSequence(self.children + [other_node]) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.children!r})" + + +class Regex(Node): + """ + Regular expression. + """ + + def __init__(self, regex: str) -> None: + re.compile(regex) # Validate + + self.regex = regex + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(/{self.regex}/)" + + +class Lookahead(Node): + """ + Lookahead expression. + """ + + def __init__(self, childnode: Node, negative: bool = False) -> None: + self.childnode = childnode + self.negative = negative + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.childnode!r})" + + +class Variable(Node): + """ + Mark a variable in the regular grammar. This will be translated into a + named group. Each variable can have his own completer, validator, etc.. + + :param childnode: The grammar which is wrapped inside this variable. + :param varname: String. + """ + + def __init__(self, childnode: Node, varname: str = "") -> None: + self.childnode = childnode + self.varname = varname + + def __repr__(self) -> str: + return "{}(childnode={!r}, varname={!r})".format( + self.__class__.__name__, + self.childnode, + self.varname, + ) + + +class Repeat(Node): + def __init__( + self, + childnode: Node, + min_repeat: int = 0, + max_repeat: int | None = None, + greedy: bool = True, + ) -> None: + self.childnode = childnode + self.min_repeat = min_repeat + self.max_repeat = max_repeat + self.greedy = greedy + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(childnode={self.childnode!r})" + + +def tokenize_regex(input: str) -> list[str]: + """ + Takes a string, representing a regular expression as input, and tokenizes + it. + + :param input: string, representing a regular expression. + :returns: List of tokens. + """ + # Regular expression for tokenizing other regular expressions. + p = re.compile( + r"""^( + \(\?P\<[a-zA-Z0-9_-]+\> | # Start of named group. + \(\?#[^)]*\) | # Comment + \(\?= | # Start of lookahead assertion + \(\?! | # Start of negative lookahead assertion + \(\?<= | # If preceded by. + \(\?< | # If not preceded by. + \(?: | # Start of group. (non capturing.) + \( | # Start of group. + \(?[iLmsux] | # Flags. + \(?P=[a-zA-Z]+\) | # Back reference to named group + \) | # End of group. + \{[^{}]*\} | # Repetition + \*\? | \+\? | \?\?\ | # Non greedy repetition. + \* | \+ | \? | # Repetition + \#.*\n | # Comment + \\. | + + # Character group. + \[ + ( [^\]\\] | \\.)* + \] | + + [^(){}] | + . + )""", + re.VERBOSE, + ) + + tokens = [] + + while input: + m = p.match(input) + if m: + token, input = input[: m.end()], input[m.end() :] + if not token.isspace(): + tokens.append(token) + else: + raise Exception("Could not tokenize input regex.") + + return tokens + + +def parse_regex(regex_tokens: list[str]) -> Node: + """ + Takes a list of tokens from the tokenizer, and returns a parse tree. + """ + # We add a closing brace because that represents the final pop of the stack. + tokens: list[str] = [")"] + regex_tokens[::-1] + + def wrap(lst: list[Node]) -> Node: + """Turn list into sequence when it contains several items.""" + if len(lst) == 1: + return lst[0] + else: + return NodeSequence(lst) + + def _parse() -> Node: + or_list: list[list[Node]] = [] + result: list[Node] = [] + + def wrapped_result() -> Node: + if or_list == []: + return wrap(result) + else: + or_list.append(result) + return AnyNode([wrap(i) for i in or_list]) + + while tokens: + t = tokens.pop() + + if t.startswith("(?P<"): + variable = Variable(_parse(), varname=t[4:-1]) + result.append(variable) + + elif t in ("*", "*?"): + greedy = t == "*" + result[-1] = Repeat(result[-1], greedy=greedy) + + elif t in ("+", "+?"): + greedy = t == "+" + result[-1] = Repeat(result[-1], min_repeat=1, greedy=greedy) + + elif t in ("?", "??"): + if result == []: + raise Exception("Nothing to repeat." + repr(tokens)) + else: + greedy = t == "?" + result[-1] = Repeat( + result[-1], min_repeat=0, max_repeat=1, greedy=greedy + ) + + elif t == "|": + or_list.append(result) + result = [] + + elif t in ("(", "(?:"): + result.append(_parse()) + + elif t == "(?!": + result.append(Lookahead(_parse(), negative=True)) + + elif t == "(?=": + result.append(Lookahead(_parse(), negative=False)) + + elif t == ")": + return wrapped_result() + + elif t.startswith("#"): + pass + + elif t.startswith("{"): + # TODO: implement! + raise Exception(f"{t}-style repetition not yet supported") + + elif t.startswith("(?"): + raise Exception("%r not supported" % t) + + elif t.isspace(): + pass + else: + result.append(Regex(t)) + + raise Exception("Expecting ')' token") + + result = _parse() + + if len(tokens) != 0: + raise Exception("Unmatched parentheses.") + else: + return result diff --git a/src/prompt_toolkit/contrib/regular_languages/validation.py b/src/prompt_toolkit/contrib/regular_languages/validation.py new file mode 100644 index 0000000..8e56e05 --- /dev/null +++ b/src/prompt_toolkit/contrib/regular_languages/validation.py @@ -0,0 +1,59 @@ +""" +Validator for a regular language. +""" +from __future__ import annotations + +from prompt_toolkit.document import Document +from prompt_toolkit.validation import ValidationError, Validator + +from .compiler import _CompiledGrammar + +__all__ = [ + "GrammarValidator", +] + + +class GrammarValidator(Validator): + """ + Validator which can be used for validation according to variables in + the grammar. Each variable can have its own validator. + + :param compiled_grammar: `GrammarCompleter` instance. + :param validators: `dict` mapping variable names of the grammar to the + `Validator` instances to be used for each variable. + """ + + def __init__( + self, compiled_grammar: _CompiledGrammar, validators: dict[str, Validator] + ) -> None: + self.compiled_grammar = compiled_grammar + self.validators = validators + + def validate(self, document: Document) -> None: + # Parse input document. + # We use `match`, not `match_prefix`, because for validation, we want + # the actual, unambiguous interpretation of the input. + m = self.compiled_grammar.match(document.text) + + if m: + for v in m.variables(): + validator = self.validators.get(v.varname) + + if validator: + # Unescape text. + unwrapped_text = self.compiled_grammar.unescape(v.varname, v.value) + + # Create a document, for the completions API (text/cursor_position) + inner_document = Document(unwrapped_text, len(unwrapped_text)) + + try: + validator.validate(inner_document) + except ValidationError as e: + raise ValidationError( + cursor_position=v.start + e.cursor_position, + message=e.message, + ) from e + else: + raise ValidationError( + cursor_position=len(document.text), message="Invalid command" + ) diff --git a/src/prompt_toolkit/contrib/ssh/__init__.py b/src/prompt_toolkit/contrib/ssh/__init__.py new file mode 100644 index 0000000..bbc1c21 --- /dev/null +++ b/src/prompt_toolkit/contrib/ssh/__init__.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from .server import PromptToolkitSSHServer, PromptToolkitSSHSession + +__all__ = [ + "PromptToolkitSSHSession", + "PromptToolkitSSHServer", +] diff --git a/src/prompt_toolkit/contrib/ssh/server.py b/src/prompt_toolkit/contrib/ssh/server.py new file mode 100644 index 0000000..9a5d402 --- /dev/null +++ b/src/prompt_toolkit/contrib/ssh/server.py @@ -0,0 +1,177 @@ +""" +Utility for running a prompt_toolkit application in an asyncssh server. +""" +from __future__ import annotations + +import asyncio +import traceback +from asyncio import get_running_loop +from typing import Any, Callable, Coroutine, TextIO, cast + +import asyncssh + +from prompt_toolkit.application.current import AppSession, create_app_session +from prompt_toolkit.data_structures import Size +from prompt_toolkit.input import PipeInput, create_pipe_input +from prompt_toolkit.output.vt100 import Vt100_Output + +__all__ = ["PromptToolkitSSHSession", "PromptToolkitSSHServer"] + + +class PromptToolkitSSHSession(asyncssh.SSHServerSession): # type: ignore + def __init__( + self, + interact: Callable[[PromptToolkitSSHSession], Coroutine[Any, Any, None]], + *, + enable_cpr: bool, + ) -> None: + self.interact = interact + self.enable_cpr = enable_cpr + self.interact_task: asyncio.Task[None] | None = None + self._chan: Any | None = None + self.app_session: AppSession | None = None + + # PipInput object, for sending input in the CLI. + # (This is something that we can use in the prompt_toolkit event loop, + # but still write date in manually.) + self._input: PipeInput | None = None + self._output: Vt100_Output | None = None + + # Output object. Don't render to the real stdout, but write everything + # in the SSH channel. + class Stdout: + def write(s, data: str) -> None: + try: + if self._chan is not None: + self._chan.write(data.replace("\n", "\r\n")) + except BrokenPipeError: + pass # Channel not open for sending. + + def isatty(s) -> bool: + return True + + def flush(s) -> None: + pass + + @property + def encoding(s) -> str: + assert self._chan is not None + return str(self._chan._orig_chan.get_encoding()[0]) + + self.stdout = cast(TextIO, Stdout()) + + def _get_size(self) -> Size: + """ + Callable that returns the current `Size`, required by Vt100_Output. + """ + if self._chan is None: + return Size(rows=20, columns=79) + else: + width, height, pixwidth, pixheight = self._chan.get_terminal_size() + return Size(rows=height, columns=width) + + def connection_made(self, chan: Any) -> None: + self._chan = chan + + def shell_requested(self) -> bool: + return True + + def session_started(self) -> None: + self.interact_task = get_running_loop().create_task(self._interact()) + + async def _interact(self) -> None: + if self._chan is None: + # Should not happen. + raise Exception("`_interact` called before `connection_made`.") + + if hasattr(self._chan, "set_line_mode") and self._chan._editor is not None: + # Disable the line editing provided by asyncssh. Prompt_toolkit + # provides the line editing. + self._chan.set_line_mode(False) + + term = self._chan.get_terminal_type() + + self._output = Vt100_Output( + self.stdout, self._get_size, term=term, enable_cpr=self.enable_cpr + ) + + with create_pipe_input() as self._input: + with create_app_session(input=self._input, output=self._output) as session: + self.app_session = session + try: + await self.interact(self) + except BaseException: + traceback.print_exc() + finally: + # Close the connection. + self._chan.close() + self._input.close() + + def terminal_size_changed( + self, width: int, height: int, pixwidth: object, pixheight: object + ) -> None: + # Send resize event to the current application. + if self.app_session and self.app_session.app: + self.app_session.app._on_resize() + + def data_received(self, data: str, datatype: object) -> None: + if self._input is None: + # Should not happen. + return + + self._input.send_text(data) + + +class PromptToolkitSSHServer(asyncssh.SSHServer): + """ + Run a prompt_toolkit application over an asyncssh server. + + This takes one argument, an `interact` function, which is called for each + connection. This should be an asynchronous function that runs the + prompt_toolkit applications. This function runs in an `AppSession`, which + means that we can have multiple UI interactions concurrently. + + Example usage: + + .. code:: python + + async def interact(ssh_session: PromptToolkitSSHSession) -> None: + await yes_no_dialog("my title", "my text").run_async() + + prompt_session = PromptSession() + text = await prompt_session.prompt_async("Type something: ") + print_formatted_text('You said: ', text) + + server = PromptToolkitSSHServer(interact=interact) + loop = get_running_loop() + loop.run_until_complete( + asyncssh.create_server( + lambda: MySSHServer(interact), + "", + port, + server_host_keys=["/etc/ssh/..."], + ) + ) + loop.run_forever() + + :param enable_cpr: When `True`, the default, try to detect whether the SSH + client runs in a terminal that responds to "cursor position requests". + That way, we can properly determine how much space there is available + for the UI (especially for drop down menus) to render. + """ + + def __init__( + self, + interact: Callable[[PromptToolkitSSHSession], Coroutine[Any, Any, None]], + *, + enable_cpr: bool = True, + ) -> None: + self.interact = interact + self.enable_cpr = enable_cpr + + def begin_auth(self, username: str) -> bool: + # No authentication. + return False + + def session_requested(self) -> PromptToolkitSSHSession: + return PromptToolkitSSHSession(self.interact, enable_cpr=self.enable_cpr) diff --git a/src/prompt_toolkit/contrib/telnet/__init__.py b/src/prompt_toolkit/contrib/telnet/__init__.py new file mode 100644 index 0000000..de902b4 --- /dev/null +++ b/src/prompt_toolkit/contrib/telnet/__init__.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from .server import TelnetServer + +__all__ = [ + "TelnetServer", +] diff --git a/src/prompt_toolkit/contrib/telnet/log.py b/src/prompt_toolkit/contrib/telnet/log.py new file mode 100644 index 0000000..0fe8433 --- /dev/null +++ b/src/prompt_toolkit/contrib/telnet/log.py @@ -0,0 +1,12 @@ +""" +Python logger for the telnet server. +""" +from __future__ import annotations + +import logging + +logger = logging.getLogger(__package__) + +__all__ = [ + "logger", +] diff --git a/src/prompt_toolkit/contrib/telnet/protocol.py b/src/prompt_toolkit/contrib/telnet/protocol.py new file mode 100644 index 0000000..4b90e98 --- /dev/null +++ b/src/prompt_toolkit/contrib/telnet/protocol.py @@ -0,0 +1,208 @@ +""" +Parser for the Telnet protocol. (Not a complete implementation of the telnet +specification, but sufficient for a command line interface.) + +Inspired by `Twisted.conch.telnet`. +""" +from __future__ import annotations + +import struct +from typing import Callable, Generator + +from .log import logger + +__all__ = [ + "TelnetProtocolParser", +] + + +def int2byte(number: int) -> bytes: + return bytes((number,)) + + +# Telnet constants. +NOP = int2byte(0) +SGA = int2byte(3) + +IAC = int2byte(255) +DO = int2byte(253) +DONT = int2byte(254) +LINEMODE = int2byte(34) +SB = int2byte(250) +WILL = int2byte(251) +WONT = int2byte(252) +MODE = int2byte(1) +SE = int2byte(240) +ECHO = int2byte(1) +NAWS = int2byte(31) +LINEMODE = int2byte(34) +SUPPRESS_GO_AHEAD = int2byte(3) + +TTYPE = int2byte(24) +SEND = int2byte(1) +IS = int2byte(0) + +DM = int2byte(242) +BRK = int2byte(243) +IP = int2byte(244) +AO = int2byte(245) +AYT = int2byte(246) +EC = int2byte(247) +EL = int2byte(248) +GA = int2byte(249) + + +class TelnetProtocolParser: + """ + Parser for the Telnet protocol. + Usage:: + + def data_received(data): + print(data) + + def size_received(rows, columns): + print(rows, columns) + + p = TelnetProtocolParser(data_received, size_received) + p.feed(binary_data) + """ + + def __init__( + self, + data_received_callback: Callable[[bytes], None], + size_received_callback: Callable[[int, int], None], + ttype_received_callback: Callable[[str], None], + ) -> None: + self.data_received_callback = data_received_callback + self.size_received_callback = size_received_callback + self.ttype_received_callback = ttype_received_callback + + self._parser = self._parse_coroutine() + self._parser.send(None) # type: ignore + + def received_data(self, data: bytes) -> None: + self.data_received_callback(data) + + def do_received(self, data: bytes) -> None: + """Received telnet DO command.""" + logger.info("DO %r", data) + + def dont_received(self, data: bytes) -> None: + """Received telnet DONT command.""" + logger.info("DONT %r", data) + + def will_received(self, data: bytes) -> None: + """Received telnet WILL command.""" + logger.info("WILL %r", data) + + def wont_received(self, data: bytes) -> None: + """Received telnet WONT command.""" + logger.info("WONT %r", data) + + def command_received(self, command: bytes, data: bytes) -> None: + if command == DO: + self.do_received(data) + + elif command == DONT: + self.dont_received(data) + + elif command == WILL: + self.will_received(data) + + elif command == WONT: + self.wont_received(data) + + else: + logger.info("command received %r %r", command, data) + + def naws(self, data: bytes) -> None: + """ + Received NAWS. (Window dimensions.) + """ + if len(data) == 4: + # NOTE: the first parameter of struct.unpack should be + # a 'str' object. Both on Py2/py3. This crashes on OSX + # otherwise. + columns, rows = struct.unpack("!HH", data) + self.size_received_callback(rows, columns) + else: + logger.warning("Wrong number of NAWS bytes") + + def ttype(self, data: bytes) -> None: + """ + Received terminal type. + """ + subcmd, data = data[0:1], data[1:] + if subcmd == IS: + ttype = data.decode("ascii") + self.ttype_received_callback(ttype) + else: + logger.warning("Received a non-IS terminal type Subnegotiation") + + def negotiate(self, data: bytes) -> None: + """ + Got negotiate data. + """ + command, payload = data[0:1], data[1:] + + if command == NAWS: + self.naws(payload) + elif command == TTYPE: + self.ttype(payload) + else: + logger.info("Negotiate (%r got bytes)", len(data)) + + def _parse_coroutine(self) -> Generator[None, bytes, None]: + """ + Parser state machine. + Every 'yield' expression returns the next byte. + """ + while True: + d = yield + + if d == int2byte(0): + pass # NOP + + # Go to state escaped. + elif d == IAC: + d2 = yield + + if d2 == IAC: + self.received_data(d2) + + # Handle simple commands. + elif d2 in (NOP, DM, BRK, IP, AO, AYT, EC, EL, GA): + self.command_received(d2, b"") + + # Handle IAC-[DO/DONT/WILL/WONT] commands. + elif d2 in (DO, DONT, WILL, WONT): + d3 = yield + self.command_received(d2, d3) + + # Subnegotiation + elif d2 == SB: + # Consume everything until next IAC-SE + data = [] + + while True: + d3 = yield + + if d3 == IAC: + d4 = yield + if d4 == SE: + break + else: + data.append(d4) + else: + data.append(d3) + + self.negotiate(b"".join(data)) + else: + self.received_data(d) + + def feed(self, data: bytes) -> None: + """ + Feed data to the parser. + """ + for b in data: + self._parser.send(int2byte(b)) diff --git a/src/prompt_toolkit/contrib/telnet/server.py b/src/prompt_toolkit/contrib/telnet/server.py new file mode 100644 index 0000000..9ebe66c --- /dev/null +++ b/src/prompt_toolkit/contrib/telnet/server.py @@ -0,0 +1,427 @@ +""" +Telnet server. +""" +from __future__ import annotations + +import asyncio +import contextvars +import socket +from asyncio import get_running_loop +from typing import Any, Callable, Coroutine, TextIO, cast + +from prompt_toolkit.application.current import create_app_session, get_app +from prompt_toolkit.application.run_in_terminal import run_in_terminal +from prompt_toolkit.data_structures import Size +from prompt_toolkit.formatted_text import AnyFormattedText, to_formatted_text +from prompt_toolkit.input import PipeInput, create_pipe_input +from prompt_toolkit.output.vt100 import Vt100_Output +from prompt_toolkit.renderer import print_formatted_text as print_formatted_text +from prompt_toolkit.styles import BaseStyle, DummyStyle + +from .log import logger +from .protocol import ( + DO, + ECHO, + IAC, + LINEMODE, + MODE, + NAWS, + SB, + SE, + SEND, + SUPPRESS_GO_AHEAD, + TTYPE, + WILL, + TelnetProtocolParser, +) + +__all__ = [ + "TelnetServer", +] + + +def int2byte(number: int) -> bytes: + return bytes((number,)) + + +def _initialize_telnet(connection: socket.socket) -> None: + logger.info("Initializing telnet connection") + + # Iac Do Linemode + connection.send(IAC + DO + LINEMODE) + + # Suppress Go Ahead. (This seems important for Putty to do correct echoing.) + # This will allow bi-directional operation. + connection.send(IAC + WILL + SUPPRESS_GO_AHEAD) + + # Iac sb + connection.send(IAC + SB + LINEMODE + MODE + int2byte(0) + IAC + SE) + + # IAC Will Echo + connection.send(IAC + WILL + ECHO) + + # Negotiate window size + connection.send(IAC + DO + NAWS) + + # Negotiate terminal type + # Assume the client will accept the negotiation with `IAC + WILL + TTYPE` + connection.send(IAC + DO + TTYPE) + + # We can then select the first terminal type supported by the client, + # which is generally the best type the client supports + # The client should reply with a `IAC + SB + TTYPE + IS + ttype + IAC + SE` + connection.send(IAC + SB + TTYPE + SEND + IAC + SE) + + +class _ConnectionStdout: + """ + Wrapper around socket which provides `write` and `flush` methods for the + Vt100_Output output. + """ + + def __init__(self, connection: socket.socket, encoding: str) -> None: + self._encoding = encoding + self._connection = connection + self._errors = "strict" + self._buffer: list[bytes] = [] + self._closed = False + + def write(self, data: str) -> None: + data = data.replace("\n", "\r\n") + self._buffer.append(data.encode(self._encoding, errors=self._errors)) + self.flush() + + def isatty(self) -> bool: + return True + + def flush(self) -> None: + try: + if not self._closed: + self._connection.send(b"".join(self._buffer)) + except OSError as e: + logger.warning("Couldn't send data over socket: %s" % e) + + self._buffer = [] + + def close(self) -> None: + self._closed = True + + @property + def encoding(self) -> str: + return self._encoding + + @property + def errors(self) -> str: + return self._errors + + +class TelnetConnection: + """ + Class that represents one Telnet connection. + """ + + def __init__( + self, + conn: socket.socket, + addr: tuple[str, int], + interact: Callable[[TelnetConnection], Coroutine[Any, Any, None]], + server: TelnetServer, + encoding: str, + style: BaseStyle | None, + vt100_input: PipeInput, + enable_cpr: bool = True, + ) -> None: + self.conn = conn + self.addr = addr + self.interact = interact + self.server = server + self.encoding = encoding + self.style = style + self._closed = False + self._ready = asyncio.Event() + self.vt100_input = vt100_input + self.enable_cpr = enable_cpr + self.vt100_output: Vt100_Output | None = None + + # Create "Output" object. + self.size = Size(rows=40, columns=79) + + # Initialize. + _initialize_telnet(conn) + + # Create output. + def get_size() -> Size: + return self.size + + self.stdout = cast(TextIO, _ConnectionStdout(conn, encoding=encoding)) + + def data_received(data: bytes) -> None: + """TelnetProtocolParser 'data_received' callback""" + self.vt100_input.send_bytes(data) + + def size_received(rows: int, columns: int) -> None: + """TelnetProtocolParser 'size_received' callback""" + self.size = Size(rows=rows, columns=columns) + if self.vt100_output is not None and self.context: + self.context.run(lambda: get_app()._on_resize()) + + def ttype_received(ttype: str) -> None: + """TelnetProtocolParser 'ttype_received' callback""" + self.vt100_output = Vt100_Output( + self.stdout, get_size, term=ttype, enable_cpr=enable_cpr + ) + self._ready.set() + + self.parser = TelnetProtocolParser(data_received, size_received, ttype_received) + self.context: contextvars.Context | None = None + + async def run_application(self) -> None: + """ + Run application. + """ + + def handle_incoming_data() -> None: + data = self.conn.recv(1024) + if data: + self.feed(data) + else: + # Connection closed by client. + logger.info("Connection closed by client. {!r} {!r}".format(*self.addr)) + self.close() + + # Add reader. + loop = get_running_loop() + loop.add_reader(self.conn, handle_incoming_data) + + try: + # Wait for v100_output to be properly instantiated + await self._ready.wait() + with create_app_session(input=self.vt100_input, output=self.vt100_output): + self.context = contextvars.copy_context() + await self.interact(self) + finally: + self.close() + + def feed(self, data: bytes) -> None: + """ + Handler for incoming data. (Called by TelnetServer.) + """ + self.parser.feed(data) + + def close(self) -> None: + """ + Closed by client. + """ + if not self._closed: + self._closed = True + + self.vt100_input.close() + get_running_loop().remove_reader(self.conn) + self.conn.close() + self.stdout.close() + + def send(self, formatted_text: AnyFormattedText) -> None: + """ + Send text to the client. + """ + if self.vt100_output is None: + return + formatted_text = to_formatted_text(formatted_text) + print_formatted_text( + self.vt100_output, formatted_text, self.style or DummyStyle() + ) + + def send_above_prompt(self, formatted_text: AnyFormattedText) -> None: + """ + Send text to the client. + This is asynchronous, returns a `Future`. + """ + formatted_text = to_formatted_text(formatted_text) + return self._run_in_terminal(lambda: self.send(formatted_text)) + + def _run_in_terminal(self, func: Callable[[], None]) -> None: + # Make sure that when an application was active for this connection, + # that we print the text above the application. + if self.context: + self.context.run(run_in_terminal, func) # type: ignore + else: + raise RuntimeError("Called _run_in_terminal outside `run_application`.") + + def erase_screen(self) -> None: + """ + Erase the screen and move the cursor to the top. + """ + if self.vt100_output is None: + return + self.vt100_output.erase_screen() + self.vt100_output.cursor_goto(0, 0) + self.vt100_output.flush() + + +async def _dummy_interact(connection: TelnetConnection) -> None: + pass + + +class TelnetServer: + """ + Telnet server implementation. + + Example:: + + async def interact(connection): + connection.send("Welcome") + session = PromptSession() + result = await session.prompt_async(message="Say something: ") + connection.send(f"You said: {result}\n") + + async def main(): + server = TelnetServer(interact=interact, port=2323) + await server.run() + """ + + def __init__( + self, + host: str = "127.0.0.1", + port: int = 23, + interact: Callable[ + [TelnetConnection], Coroutine[Any, Any, None] + ] = _dummy_interact, + encoding: str = "utf-8", + style: BaseStyle | None = None, + enable_cpr: bool = True, + ) -> None: + self.host = host + self.port = port + self.interact = interact + self.encoding = encoding + self.style = style + self.enable_cpr = enable_cpr + + self._run_task: asyncio.Task[None] | None = None + self._application_tasks: list[asyncio.Task[None]] = [] + + self.connections: set[TelnetConnection] = set() + + @classmethod + def _create_socket(cls, host: str, port: int) -> socket.socket: + # Create and bind socket + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind((host, port)) + + s.listen(4) + return s + + async def run(self, ready_cb: Callable[[], None] | None = None) -> None: + """ + Run the telnet server, until this gets cancelled. + + :param ready_cb: Callback that will be called at the point that we're + actually listening. + """ + socket = self._create_socket(self.host, self.port) + logger.info( + "Listening for telnet connections on %s port %r", self.host, self.port + ) + + get_running_loop().add_reader(socket, lambda: self._accept(socket)) + + if ready_cb: + ready_cb() + + try: + # Run forever, until cancelled. + await asyncio.Future() + finally: + get_running_loop().remove_reader(socket) + socket.close() + + # Wait for all applications to finish. + for t in self._application_tasks: + t.cancel() + + # (This is similar to + # `Application.cancel_and_wait_for_background_tasks`. We wait for the + # background tasks to complete, but don't propagate exceptions, because + # we can't use `ExceptionGroup` yet.) + if len(self._application_tasks) > 0: + await asyncio.wait( + self._application_tasks, + timeout=None, + return_when=asyncio.ALL_COMPLETED, + ) + + def start(self) -> None: + """ + Deprecated: Use `.run()` instead. + + Start the telnet server (stop by calling and awaiting `stop()`). + """ + if self._run_task is not None: + # Already running. + return + + self._run_task = get_running_loop().create_task(self.run()) + + async def stop(self) -> None: + """ + Deprecated: Use `.run()` instead. + + Stop a telnet server that was started using `.start()` and wait for the + cancellation to complete. + """ + if self._run_task is not None: + self._run_task.cancel() + try: + await self._run_task + except asyncio.CancelledError: + pass + + def _accept(self, listen_socket: socket.socket) -> None: + """ + Accept new incoming connection. + """ + conn, addr = listen_socket.accept() + logger.info("New connection %r %r", *addr) + + # Run application for this connection. + async def run() -> None: + try: + with create_pipe_input() as vt100_input: + connection = TelnetConnection( + conn, + addr, + self.interact, + self, + encoding=self.encoding, + style=self.style, + vt100_input=vt100_input, + enable_cpr=self.enable_cpr, + ) + self.connections.add(connection) + + logger.info("Starting interaction %r %r", *addr) + try: + await connection.run_application() + finally: + self.connections.remove(connection) + logger.info("Stopping interaction %r %r", *addr) + except EOFError: + # Happens either when the connection is closed by the client + # (e.g., when the user types 'control-]', then 'quit' in the + # telnet client) or when the user types control-d in a prompt + # and this is not handled by the interact function. + logger.info("Unhandled EOFError in telnet application.") + except KeyboardInterrupt: + # Unhandled control-c propagated by a prompt. + logger.info("Unhandled KeyboardInterrupt in telnet application.") + except BaseException as e: + print("Got %s" % type(e).__name__, e) + import traceback + + traceback.print_exc() + finally: + self._application_tasks.remove(task) + + task = get_running_loop().create_task(run()) + self._application_tasks.append(task) diff --git a/src/prompt_toolkit/cursor_shapes.py b/src/prompt_toolkit/cursor_shapes.py new file mode 100644 index 0000000..453b72c --- /dev/null +++ b/src/prompt_toolkit/cursor_shapes.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from enum import Enum +from typing import TYPE_CHECKING, Any, Callable, Union + +from prompt_toolkit.enums import EditingMode +from prompt_toolkit.key_binding.vi_state import InputMode + +if TYPE_CHECKING: + from .application import Application + +__all__ = [ + "CursorShape", + "CursorShapeConfig", + "SimpleCursorShapeConfig", + "ModalCursorShapeConfig", + "DynamicCursorShapeConfig", + "to_cursor_shape_config", +] + + +class CursorShape(Enum): + # Default value that should tell the output implementation to never send + # cursor shape escape sequences. This is the default right now, because + # before this `CursorShape` functionality was introduced into + # prompt_toolkit itself, people had workarounds to send cursor shapes + # escapes into the terminal, by monkey patching some of prompt_toolkit's + # internals. We don't want the default prompt_toolkit implementation to + # interfere with that. E.g., IPython patches the `ViState.input_mode` + # property. See: https://github.com/ipython/ipython/pull/13501/files + _NEVER_CHANGE = "_NEVER_CHANGE" + + BLOCK = "BLOCK" + BEAM = "BEAM" + UNDERLINE = "UNDERLINE" + BLINKING_BLOCK = "BLINKING_BLOCK" + BLINKING_BEAM = "BLINKING_BEAM" + BLINKING_UNDERLINE = "BLINKING_UNDERLINE" + + +class CursorShapeConfig(ABC): + @abstractmethod + def get_cursor_shape(self, application: Application[Any]) -> CursorShape: + """ + Return the cursor shape to be used in the current state. + """ + + +AnyCursorShapeConfig = Union[CursorShape, CursorShapeConfig, None] + + +class SimpleCursorShapeConfig(CursorShapeConfig): + """ + Always show the given cursor shape. + """ + + def __init__(self, cursor_shape: CursorShape = CursorShape._NEVER_CHANGE) -> None: + self.cursor_shape = cursor_shape + + def get_cursor_shape(self, application: Application[Any]) -> CursorShape: + return self.cursor_shape + + +class ModalCursorShapeConfig(CursorShapeConfig): + """ + Show cursor shape according to the current input mode. + """ + + def get_cursor_shape(self, application: Application[Any]) -> CursorShape: + if application.editing_mode == EditingMode.VI: + if application.vi_state.input_mode == InputMode.INSERT: + return CursorShape.BEAM + if application.vi_state.input_mode == InputMode.REPLACE: + return CursorShape.UNDERLINE + + # Default + return CursorShape.BLOCK + + +class DynamicCursorShapeConfig(CursorShapeConfig): + def __init__( + self, get_cursor_shape_config: Callable[[], AnyCursorShapeConfig] + ) -> None: + self.get_cursor_shape_config = get_cursor_shape_config + + def get_cursor_shape(self, application: Application[Any]) -> CursorShape: + return to_cursor_shape_config(self.get_cursor_shape_config()).get_cursor_shape( + application + ) + + +def to_cursor_shape_config(value: AnyCursorShapeConfig) -> CursorShapeConfig: + """ + Take a `CursorShape` instance or `CursorShapeConfig` and turn it into a + `CursorShapeConfig`. + """ + if value is None: + return SimpleCursorShapeConfig() + + if isinstance(value, CursorShape): + return SimpleCursorShapeConfig(value) + + return value diff --git a/src/prompt_toolkit/data_structures.py b/src/prompt_toolkit/data_structures.py new file mode 100644 index 0000000..27dd458 --- /dev/null +++ b/src/prompt_toolkit/data_structures.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from typing import NamedTuple + +__all__ = [ + "Point", + "Size", +] + + +class Point(NamedTuple): + x: int + y: int + + +class Size(NamedTuple): + rows: int + columns: int diff --git a/src/prompt_toolkit/document.py b/src/prompt_toolkit/document.py new file mode 100644 index 0000000..74f4c13 --- /dev/null +++ b/src/prompt_toolkit/document.py @@ -0,0 +1,1181 @@ +""" +The `Document` that implements all the text operations/querying. +""" +from __future__ import annotations + +import bisect +import re +import string +import weakref +from typing import Callable, Dict, Iterable, List, NoReturn, Pattern, cast + +from .clipboard import ClipboardData +from .filters import vi_mode +from .selection import PasteMode, SelectionState, SelectionType + +__all__ = [ + "Document", +] + + +# Regex for finding "words" in documents. (We consider a group of alnum +# characters a word, but also a group of special characters a word, as long as +# it doesn't contain a space.) +# (This is a 'word' in Vi.) +_FIND_WORD_RE = re.compile(r"([a-zA-Z0-9_]+|[^a-zA-Z0-9_\s]+)") +_FIND_CURRENT_WORD_RE = re.compile(r"^([a-zA-Z0-9_]+|[^a-zA-Z0-9_\s]+)") +_FIND_CURRENT_WORD_INCLUDE_TRAILING_WHITESPACE_RE = re.compile( + r"^(([a-zA-Z0-9_]+|[^a-zA-Z0-9_\s]+)\s*)" +) + +# Regex for finding "WORDS" in documents. +# (This is a 'WORD in Vi.) +_FIND_BIG_WORD_RE = re.compile(r"([^\s]+)") +_FIND_CURRENT_BIG_WORD_RE = re.compile(r"^([^\s]+)") +_FIND_CURRENT_BIG_WORD_INCLUDE_TRAILING_WHITESPACE_RE = re.compile(r"^([^\s]+\s*)") + +# Share the Document._cache between all Document instances. +# (Document instances are considered immutable. That means that if another +# `Document` is constructed with the same text, it should have the same +# `_DocumentCache`.) +_text_to_document_cache: dict[str, _DocumentCache] = cast( + Dict[str, "_DocumentCache"], + weakref.WeakValueDictionary(), # Maps document.text to DocumentCache instance. +) + + +class _ImmutableLineList(List[str]): + """ + Some protection for our 'lines' list, which is assumed to be immutable in the cache. + (Useful for detecting obvious bugs.) + """ + + def _error(self, *a: object, **kw: object) -> NoReturn: + raise NotImplementedError("Attempt to modify an immutable list.") + + __setitem__ = _error # type: ignore + append = _error + clear = _error + extend = _error + insert = _error + pop = _error + remove = _error + reverse = _error + sort = _error # type: ignore + + +class _DocumentCache: + def __init__(self) -> None: + #: List of lines for the Document text. + self.lines: _ImmutableLineList | None = None + + #: List of index positions, pointing to the start of all the lines. + self.line_indexes: list[int] | None = None + + +class Document: + """ + This is a immutable class around the text and cursor position, and contains + methods for querying this data, e.g. to give the text before the cursor. + + This class is usually instantiated by a :class:`~prompt_toolkit.buffer.Buffer` + object, and accessed as the `document` property of that class. + + :param text: string + :param cursor_position: int + :param selection: :class:`.SelectionState` + """ + + __slots__ = ("_text", "_cursor_position", "_selection", "_cache") + + def __init__( + self, + text: str = "", + cursor_position: int | None = None, + selection: SelectionState | None = None, + ) -> None: + # Check cursor position. It can also be right after the end. (Where we + # insert text.) + assert cursor_position is None or cursor_position <= len(text), AssertionError( + f"cursor_position={cursor_position!r}, len_text={len(text)!r}" + ) + + # By default, if no cursor position was given, make sure to put the + # cursor position is at the end of the document. This is what makes + # sense in most places. + if cursor_position is None: + cursor_position = len(text) + + # Keep these attributes private. A `Document` really has to be + # considered to be immutable, because otherwise the caching will break + # things. Because of that, we wrap these into read-only properties. + self._text = text + self._cursor_position = cursor_position + self._selection = selection + + # Cache for lines/indexes. (Shared with other Document instances that + # contain the same text. + try: + self._cache = _text_to_document_cache[self.text] + except KeyError: + self._cache = _DocumentCache() + _text_to_document_cache[self.text] = self._cache + + # XX: For some reason, above, we can't use 'WeakValueDictionary.setdefault'. + # This fails in Pypy3. `self._cache` becomes None, because that's what + # 'setdefault' returns. + # self._cache = _text_to_document_cache.setdefault(self.text, _DocumentCache()) + # assert self._cache + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.text!r}, {self.cursor_position!r})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Document): + return False + + return ( + self.text == other.text + and self.cursor_position == other.cursor_position + and self.selection == other.selection + ) + + @property + def text(self) -> str: + "The document text." + return self._text + + @property + def cursor_position(self) -> int: + "The document cursor position." + return self._cursor_position + + @property + def selection(self) -> SelectionState | None: + ":class:`.SelectionState` object." + return self._selection + + @property + def current_char(self) -> str: + """Return character under cursor or an empty string.""" + return self._get_char_relative_to_cursor(0) or "" + + @property + def char_before_cursor(self) -> str: + """Return character before the cursor or an empty string.""" + return self._get_char_relative_to_cursor(-1) or "" + + @property + def text_before_cursor(self) -> str: + return self.text[: self.cursor_position :] + + @property + def text_after_cursor(self) -> str: + return self.text[self.cursor_position :] + + @property + def current_line_before_cursor(self) -> str: + """Text from the start of the line until the cursor.""" + _, _, text = self.text_before_cursor.rpartition("\n") + return text + + @property + def current_line_after_cursor(self) -> str: + """Text from the cursor until the end of the line.""" + text, _, _ = self.text_after_cursor.partition("\n") + return text + + @property + def lines(self) -> list[str]: + """ + Array of all the lines. + """ + # Cache, because this one is reused very often. + if self._cache.lines is None: + self._cache.lines = _ImmutableLineList(self.text.split("\n")) + + return self._cache.lines + + @property + def _line_start_indexes(self) -> list[int]: + """ + Array pointing to the start indexes of all the lines. + """ + # Cache, because this is often reused. (If it is used, it's often used + # many times. And this has to be fast for editing big documents!) + if self._cache.line_indexes is None: + # Create list of line lengths. + line_lengths = map(len, self.lines) + + # Calculate cumulative sums. + indexes = [0] + append = indexes.append + pos = 0 + + for line_length in line_lengths: + pos += line_length + 1 + append(pos) + + # Remove the last item. (This is not a new line.) + if len(indexes) > 1: + indexes.pop() + + self._cache.line_indexes = indexes + + return self._cache.line_indexes + + @property + def lines_from_current(self) -> list[str]: + """ + Array of the lines starting from the current line, until the last line. + """ + return self.lines[self.cursor_position_row :] + + @property + def line_count(self) -> int: + r"""Return the number of lines in this document. If the document ends + with a trailing \n, that counts as the beginning of a new line.""" + return len(self.lines) + + @property + def current_line(self) -> str: + """Return the text on the line where the cursor is. (when the input + consists of just one line, it equals `text`.""" + return self.current_line_before_cursor + self.current_line_after_cursor + + @property + def leading_whitespace_in_current_line(self) -> str: + """The leading whitespace in the left margin of the current line.""" + current_line = self.current_line + length = len(current_line) - len(current_line.lstrip()) + return current_line[:length] + + def _get_char_relative_to_cursor(self, offset: int = 0) -> str: + """ + Return character relative to cursor position, or empty string + """ + try: + return self.text[self.cursor_position + offset] + except IndexError: + return "" + + @property + def on_first_line(self) -> bool: + """ + True when we are at the first line. + """ + return self.cursor_position_row == 0 + + @property + def on_last_line(self) -> bool: + """ + True when we are at the last line. + """ + return self.cursor_position_row == self.line_count - 1 + + @property + def cursor_position_row(self) -> int: + """ + Current row. (0-based.) + """ + row, _ = self._find_line_start_index(self.cursor_position) + return row + + @property + def cursor_position_col(self) -> int: + """ + Current column. (0-based.) + """ + # (Don't use self.text_before_cursor to calculate this. Creating + # substrings and doing rsplit is too expensive for getting the cursor + # position.) + _, line_start_index = self._find_line_start_index(self.cursor_position) + return self.cursor_position - line_start_index + + def _find_line_start_index(self, index: int) -> tuple[int, int]: + """ + For the index of a character at a certain line, calculate the index of + the first character on that line. + + Return (row, index) tuple. + """ + indexes = self._line_start_indexes + + pos = bisect.bisect_right(indexes, index) - 1 + return pos, indexes[pos] + + def translate_index_to_position(self, index: int) -> tuple[int, int]: + """ + Given an index for the text, return the corresponding (row, col) tuple. + (0-based. Returns (0, 0) for index=0.) + """ + # Find start of this line. + row, row_index = self._find_line_start_index(index) + col = index - row_index + + return row, col + + def translate_row_col_to_index(self, row: int, col: int) -> int: + """ + Given a (row, col) tuple, return the corresponding index. + (Row and col params are 0-based.) + + Negative row/col values are turned into zero. + """ + try: + result = self._line_start_indexes[row] + line = self.lines[row] + except IndexError: + if row < 0: + result = self._line_start_indexes[0] + line = self.lines[0] + else: + result = self._line_start_indexes[-1] + line = self.lines[-1] + + result += max(0, min(col, len(line))) + + # Keep in range. (len(self.text) is included, because the cursor can be + # right after the end of the text as well.) + result = max(0, min(result, len(self.text))) + return result + + @property + def is_cursor_at_the_end(self) -> bool: + """True when the cursor is at the end of the text.""" + return self.cursor_position == len(self.text) + + @property + def is_cursor_at_the_end_of_line(self) -> bool: + """True when the cursor is at the end of this line.""" + return self.current_char in ("\n", "") + + def has_match_at_current_position(self, sub: str) -> bool: + """ + `True` when this substring is found at the cursor position. + """ + return self.text.find(sub, self.cursor_position) == self.cursor_position + + def find( + self, + sub: str, + in_current_line: bool = False, + include_current_position: bool = False, + ignore_case: bool = False, + count: int = 1, + ) -> int | None: + """ + Find `text` after the cursor, return position relative to the cursor + position. Return `None` if nothing was found. + + :param count: Find the n-th occurrence. + """ + assert isinstance(ignore_case, bool) + + if in_current_line: + text = self.current_line_after_cursor + else: + text = self.text_after_cursor + + if not include_current_position: + if len(text) == 0: + return None # (Otherwise, we always get a match for the empty string.) + else: + text = text[1:] + + flags = re.IGNORECASE if ignore_case else 0 + iterator = re.finditer(re.escape(sub), text, flags) + + try: + for i, match in enumerate(iterator): + if i + 1 == count: + if include_current_position: + return match.start(0) + else: + return match.start(0) + 1 + except StopIteration: + pass + return None + + def find_all(self, sub: str, ignore_case: bool = False) -> list[int]: + """ + Find all occurrences of the substring. Return a list of absolute + positions in the document. + """ + flags = re.IGNORECASE if ignore_case else 0 + return [a.start() for a in re.finditer(re.escape(sub), self.text, flags)] + + def find_backwards( + self, + sub: str, + in_current_line: bool = False, + ignore_case: bool = False, + count: int = 1, + ) -> int | None: + """ + Find `text` before the cursor, return position relative to the cursor + position. Return `None` if nothing was found. + + :param count: Find the n-th occurrence. + """ + if in_current_line: + before_cursor = self.current_line_before_cursor[::-1] + else: + before_cursor = self.text_before_cursor[::-1] + + flags = re.IGNORECASE if ignore_case else 0 + iterator = re.finditer(re.escape(sub[::-1]), before_cursor, flags) + + try: + for i, match in enumerate(iterator): + if i + 1 == count: + return -match.start(0) - len(sub) + except StopIteration: + pass + return None + + def get_word_before_cursor( + self, WORD: bool = False, pattern: Pattern[str] | None = None + ) -> str: + """ + Give the word before the cursor. + If we have whitespace before the cursor this returns an empty string. + + :param pattern: (None or compiled regex). When given, use this regex + pattern. + """ + if self._is_word_before_cursor_complete(WORD=WORD, pattern=pattern): + # Space before the cursor or no text before cursor. + return "" + + text_before_cursor = self.text_before_cursor + start = self.find_start_of_previous_word(WORD=WORD, pattern=pattern) or 0 + + return text_before_cursor[len(text_before_cursor) + start :] + + def _is_word_before_cursor_complete( + self, WORD: bool = False, pattern: Pattern[str] | None = None + ) -> bool: + if pattern: + return self.find_start_of_previous_word(WORD=WORD, pattern=pattern) is None + else: + return ( + self.text_before_cursor == "" or self.text_before_cursor[-1:].isspace() + ) + + def find_start_of_previous_word( + self, count: int = 1, WORD: bool = False, pattern: Pattern[str] | None = None + ) -> int | None: + """ + Return an index relative to the cursor position pointing to the start + of the previous word. Return `None` if nothing was found. + + :param pattern: (None or compiled regex). When given, use this regex + pattern. + """ + assert not (WORD and pattern) + + # Reverse the text before the cursor, in order to do an efficient + # backwards search. + text_before_cursor = self.text_before_cursor[::-1] + + if pattern: + regex = pattern + elif WORD: + regex = _FIND_BIG_WORD_RE + else: + regex = _FIND_WORD_RE + + iterator = regex.finditer(text_before_cursor) + + try: + for i, match in enumerate(iterator): + if i + 1 == count: + return -match.end(0) + except StopIteration: + pass + return None + + def find_boundaries_of_current_word( + self, + WORD: bool = False, + include_leading_whitespace: bool = False, + include_trailing_whitespace: bool = False, + ) -> tuple[int, int]: + """ + Return the relative boundaries (startpos, endpos) of the current word under the + cursor. (This is at the current line, because line boundaries obviously + don't belong to any word.) + If not on a word, this returns (0,0) + """ + text_before_cursor = self.current_line_before_cursor[::-1] + text_after_cursor = self.current_line_after_cursor + + def get_regex(include_whitespace: bool) -> Pattern[str]: + return { + (False, False): _FIND_CURRENT_WORD_RE, + (False, True): _FIND_CURRENT_WORD_INCLUDE_TRAILING_WHITESPACE_RE, + (True, False): _FIND_CURRENT_BIG_WORD_RE, + (True, True): _FIND_CURRENT_BIG_WORD_INCLUDE_TRAILING_WHITESPACE_RE, + }[(WORD, include_whitespace)] + + match_before = get_regex(include_leading_whitespace).search(text_before_cursor) + match_after = get_regex(include_trailing_whitespace).search(text_after_cursor) + + # When there is a match before and after, and we're not looking for + # WORDs, make sure that both the part before and after the cursor are + # either in the [a-zA-Z_] alphabet or not. Otherwise, drop the part + # before the cursor. + if not WORD and match_before and match_after: + c1 = self.text[self.cursor_position - 1] + c2 = self.text[self.cursor_position] + alphabet = string.ascii_letters + "0123456789_" + + if (c1 in alphabet) != (c2 in alphabet): + match_before = None + + return ( + -match_before.end(1) if match_before else 0, + match_after.end(1) if match_after else 0, + ) + + def get_word_under_cursor(self, WORD: bool = False) -> str: + """ + Return the word, currently below the cursor. + This returns an empty string when the cursor is on a whitespace region. + """ + start, end = self.find_boundaries_of_current_word(WORD=WORD) + return self.text[self.cursor_position + start : self.cursor_position + end] + + def find_next_word_beginning( + self, count: int = 1, WORD: bool = False + ) -> int | None: + """ + Return an index relative to the cursor position pointing to the start + of the next word. Return `None` if nothing was found. + """ + if count < 0: + return self.find_previous_word_beginning(count=-count, WORD=WORD) + + regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE + iterator = regex.finditer(self.text_after_cursor) + + try: + for i, match in enumerate(iterator): + # Take first match, unless it's the word on which we're right now. + if i == 0 and match.start(1) == 0: + count += 1 + + if i + 1 == count: + return match.start(1) + except StopIteration: + pass + return None + + def find_next_word_ending( + self, include_current_position: bool = False, count: int = 1, WORD: bool = False + ) -> int | None: + """ + Return an index relative to the cursor position pointing to the end + of the next word. Return `None` if nothing was found. + """ + if count < 0: + return self.find_previous_word_ending(count=-count, WORD=WORD) + + if include_current_position: + text = self.text_after_cursor + else: + text = self.text_after_cursor[1:] + + regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE + iterable = regex.finditer(text) + + try: + for i, match in enumerate(iterable): + if i + 1 == count: + value = match.end(1) + + if include_current_position: + return value + else: + return value + 1 + + except StopIteration: + pass + return None + + def find_previous_word_beginning( + self, count: int = 1, WORD: bool = False + ) -> int | None: + """ + Return an index relative to the cursor position pointing to the start + of the previous word. Return `None` if nothing was found. + """ + if count < 0: + return self.find_next_word_beginning(count=-count, WORD=WORD) + + regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE + iterator = regex.finditer(self.text_before_cursor[::-1]) + + try: + for i, match in enumerate(iterator): + if i + 1 == count: + return -match.end(1) + except StopIteration: + pass + return None + + def find_previous_word_ending( + self, count: int = 1, WORD: bool = False + ) -> int | None: + """ + Return an index relative to the cursor position pointing to the end + of the previous word. Return `None` if nothing was found. + """ + if count < 0: + return self.find_next_word_ending(count=-count, WORD=WORD) + + text_before_cursor = self.text_after_cursor[:1] + self.text_before_cursor[::-1] + + regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE + iterator = regex.finditer(text_before_cursor) + + try: + for i, match in enumerate(iterator): + # Take first match, unless it's the word on which we're right now. + if i == 0 and match.start(1) == 0: + count += 1 + + if i + 1 == count: + return -match.start(1) + 1 + except StopIteration: + pass + return None + + def find_next_matching_line( + self, match_func: Callable[[str], bool], count: int = 1 + ) -> int | None: + """ + Look downwards for empty lines. + Return the line index, relative to the current line. + """ + result = None + + for index, line in enumerate(self.lines[self.cursor_position_row + 1 :]): + if match_func(line): + result = 1 + index + count -= 1 + + if count == 0: + break + + return result + + def find_previous_matching_line( + self, match_func: Callable[[str], bool], count: int = 1 + ) -> int | None: + """ + Look upwards for empty lines. + Return the line index, relative to the current line. + """ + result = None + + for index, line in enumerate(self.lines[: self.cursor_position_row][::-1]): + if match_func(line): + result = -1 - index + count -= 1 + + if count == 0: + break + + return result + + def get_cursor_left_position(self, count: int = 1) -> int: + """ + Relative position for cursor left. + """ + if count < 0: + return self.get_cursor_right_position(-count) + + return -min(self.cursor_position_col, count) + + def get_cursor_right_position(self, count: int = 1) -> int: + """ + Relative position for cursor_right. + """ + if count < 0: + return self.get_cursor_left_position(-count) + + return min(count, len(self.current_line_after_cursor)) + + def get_cursor_up_position( + self, count: int = 1, preferred_column: int | None = None + ) -> int: + """ + Return the relative cursor position (character index) where we would be if the + user pressed the arrow-up button. + + :param preferred_column: When given, go to this column instead of + staying at the current column. + """ + assert count >= 1 + column = ( + self.cursor_position_col if preferred_column is None else preferred_column + ) + + return ( + self.translate_row_col_to_index( + max(0, self.cursor_position_row - count), column + ) + - self.cursor_position + ) + + def get_cursor_down_position( + self, count: int = 1, preferred_column: int | None = None + ) -> int: + """ + Return the relative cursor position (character index) where we would be if the + user pressed the arrow-down button. + + :param preferred_column: When given, go to this column instead of + staying at the current column. + """ + assert count >= 1 + column = ( + self.cursor_position_col if preferred_column is None else preferred_column + ) + + return ( + self.translate_row_col_to_index(self.cursor_position_row + count, column) + - self.cursor_position + ) + + def find_enclosing_bracket_right( + self, left_ch: str, right_ch: str, end_pos: int | None = None + ) -> int | None: + """ + Find the right bracket enclosing current position. Return the relative + position to the cursor position. + + When `end_pos` is given, don't look past the position. + """ + if self.current_char == right_ch: + return 0 + + if end_pos is None: + end_pos = len(self.text) + else: + end_pos = min(len(self.text), end_pos) + + stack = 1 + + # Look forward. + for i in range(self.cursor_position + 1, end_pos): + c = self.text[i] + + if c == left_ch: + stack += 1 + elif c == right_ch: + stack -= 1 + + if stack == 0: + return i - self.cursor_position + + return None + + def find_enclosing_bracket_left( + self, left_ch: str, right_ch: str, start_pos: int | None = None + ) -> int | None: + """ + Find the left bracket enclosing current position. Return the relative + position to the cursor position. + + When `start_pos` is given, don't look past the position. + """ + if self.current_char == left_ch: + return 0 + + if start_pos is None: + start_pos = 0 + else: + start_pos = max(0, start_pos) + + stack = 1 + + # Look backward. + for i in range(self.cursor_position - 1, start_pos - 1, -1): + c = self.text[i] + + if c == right_ch: + stack += 1 + elif c == left_ch: + stack -= 1 + + if stack == 0: + return i - self.cursor_position + + return None + + def find_matching_bracket_position( + self, start_pos: int | None = None, end_pos: int | None = None + ) -> int: + """ + Return relative cursor position of matching [, (, { or < bracket. + + When `start_pos` or `end_pos` are given. Don't look past the positions. + """ + + # Look for a match. + for pair in "()", "[]", "{}", "<>": + A = pair[0] + B = pair[1] + if self.current_char == A: + return self.find_enclosing_bracket_right(A, B, end_pos=end_pos) or 0 + elif self.current_char == B: + return self.find_enclosing_bracket_left(A, B, start_pos=start_pos) or 0 + + return 0 + + def get_start_of_document_position(self) -> int: + """Relative position for the start of the document.""" + return -self.cursor_position + + def get_end_of_document_position(self) -> int: + """Relative position for the end of the document.""" + return len(self.text) - self.cursor_position + + def get_start_of_line_position(self, after_whitespace: bool = False) -> int: + """Relative position for the start of this line.""" + if after_whitespace: + current_line = self.current_line + return ( + len(current_line) + - len(current_line.lstrip()) + - self.cursor_position_col + ) + else: + return -len(self.current_line_before_cursor) + + def get_end_of_line_position(self) -> int: + """Relative position for the end of this line.""" + return len(self.current_line_after_cursor) + + def last_non_blank_of_current_line_position(self) -> int: + """ + Relative position for the last non blank character of this line. + """ + return len(self.current_line.rstrip()) - self.cursor_position_col - 1 + + def get_column_cursor_position(self, column: int) -> int: + """ + Return the relative cursor position for this column at the current + line. (It will stay between the boundaries of the line in case of a + larger number.) + """ + line_length = len(self.current_line) + current_column = self.cursor_position_col + column = max(0, min(line_length, column)) + + return column - current_column + + def selection_range( + self, + ) -> tuple[ + int, int + ]: # XXX: shouldn't this return `None` if there is no selection??? + """ + Return (from, to) tuple of the selection. + start and end position are included. + + This doesn't take the selection type into account. Use + `selection_ranges` instead. + """ + if self.selection: + from_, to = sorted( + [self.cursor_position, self.selection.original_cursor_position] + ) + else: + from_, to = self.cursor_position, self.cursor_position + + return from_, to + + def selection_ranges(self) -> Iterable[tuple[int, int]]: + """ + Return a list of `(from, to)` tuples for the selection or none if + nothing was selected. The upper boundary is not included. + + This will yield several (from, to) tuples in case of a BLOCK selection. + This will return zero ranges, like (8,8) for empty lines in a block + selection. + """ + if self.selection: + from_, to = sorted( + [self.cursor_position, self.selection.original_cursor_position] + ) + + if self.selection.type == SelectionType.BLOCK: + from_line, from_column = self.translate_index_to_position(from_) + to_line, to_column = self.translate_index_to_position(to) + from_column, to_column = sorted([from_column, to_column]) + lines = self.lines + + if vi_mode(): + to_column += 1 + + for l in range(from_line, to_line + 1): + line_length = len(lines[l]) + + if from_column <= line_length: + yield ( + self.translate_row_col_to_index(l, from_column), + self.translate_row_col_to_index( + l, min(line_length, to_column) + ), + ) + else: + # In case of a LINES selection, go to the start/end of the lines. + if self.selection.type == SelectionType.LINES: + from_ = max(0, self.text.rfind("\n", 0, from_) + 1) + + if self.text.find("\n", to) >= 0: + to = self.text.find("\n", to) + else: + to = len(self.text) - 1 + + # In Vi mode, the upper boundary is always included. For Emacs, + # that's not the case. + if vi_mode(): + to += 1 + + yield from_, to + + def selection_range_at_line(self, row: int) -> tuple[int, int] | None: + """ + If the selection spans a portion of the given line, return a (from, to) tuple. + + The returned upper boundary is not included in the selection, so + `(0, 0)` is an empty selection. `(0, 1)`, is a one character selection. + + Returns None if the selection doesn't cover this line at all. + """ + if self.selection: + line = self.lines[row] + + row_start = self.translate_row_col_to_index(row, 0) + row_end = self.translate_row_col_to_index(row, len(line)) + + from_, to = sorted( + [self.cursor_position, self.selection.original_cursor_position] + ) + + # Take the intersection of the current line and the selection. + intersection_start = max(row_start, from_) + intersection_end = min(row_end, to) + + if intersection_start <= intersection_end: + if self.selection.type == SelectionType.LINES: + intersection_start = row_start + intersection_end = row_end + + elif self.selection.type == SelectionType.BLOCK: + _, col1 = self.translate_index_to_position(from_) + _, col2 = self.translate_index_to_position(to) + col1, col2 = sorted([col1, col2]) + + if col1 > len(line): + return None # Block selection doesn't cross this line. + + intersection_start = self.translate_row_col_to_index(row, col1) + intersection_end = self.translate_row_col_to_index(row, col2) + + _, from_column = self.translate_index_to_position(intersection_start) + _, to_column = self.translate_index_to_position(intersection_end) + + # In Vi mode, the upper boundary is always included. For Emacs + # mode, that's not the case. + if vi_mode(): + to_column += 1 + + return from_column, to_column + return None + + def cut_selection(self) -> tuple[Document, ClipboardData]: + """ + Return a (:class:`.Document`, :class:`.ClipboardData`) tuple, where the + document represents the new document when the selection is cut, and the + clipboard data, represents whatever has to be put on the clipboard. + """ + if self.selection: + cut_parts = [] + remaining_parts = [] + new_cursor_position = self.cursor_position + + last_to = 0 + for from_, to in self.selection_ranges(): + if last_to == 0: + new_cursor_position = from_ + + remaining_parts.append(self.text[last_to:from_]) + cut_parts.append(self.text[from_:to]) + last_to = to + + remaining_parts.append(self.text[last_to:]) + + cut_text = "\n".join(cut_parts) + remaining_text = "".join(remaining_parts) + + # In case of a LINES selection, don't include the trailing newline. + if self.selection.type == SelectionType.LINES and cut_text.endswith("\n"): + cut_text = cut_text[:-1] + + return ( + Document(text=remaining_text, cursor_position=new_cursor_position), + ClipboardData(cut_text, self.selection.type), + ) + else: + return self, ClipboardData("") + + def paste_clipboard_data( + self, + data: ClipboardData, + paste_mode: PasteMode = PasteMode.EMACS, + count: int = 1, + ) -> Document: + """ + Return a new :class:`.Document` instance which contains the result if + we would paste this data at the current cursor position. + + :param paste_mode: Where to paste. (Before/after/emacs.) + :param count: When >1, Paste multiple times. + """ + before = paste_mode == PasteMode.VI_BEFORE + after = paste_mode == PasteMode.VI_AFTER + + if data.type == SelectionType.CHARACTERS: + if after: + new_text = ( + self.text[: self.cursor_position + 1] + + data.text * count + + self.text[self.cursor_position + 1 :] + ) + else: + new_text = ( + self.text_before_cursor + data.text * count + self.text_after_cursor + ) + + new_cursor_position = self.cursor_position + len(data.text) * count + if before: + new_cursor_position -= 1 + + elif data.type == SelectionType.LINES: + l = self.cursor_position_row + if before: + lines = self.lines[:l] + [data.text] * count + self.lines[l:] + new_text = "\n".join(lines) + new_cursor_position = len("".join(self.lines[:l])) + l + else: + lines = self.lines[: l + 1] + [data.text] * count + self.lines[l + 1 :] + new_cursor_position = len("".join(self.lines[: l + 1])) + l + 1 + new_text = "\n".join(lines) + + elif data.type == SelectionType.BLOCK: + lines = self.lines[:] + start_line = self.cursor_position_row + start_column = self.cursor_position_col + (0 if before else 1) + + for i, line in enumerate(data.text.split("\n")): + index = i + start_line + if index >= len(lines): + lines.append("") + + lines[index] = lines[index].ljust(start_column) + lines[index] = ( + lines[index][:start_column] + + line * count + + lines[index][start_column:] + ) + + new_text = "\n".join(lines) + new_cursor_position = self.cursor_position + (0 if before else 1) + + return Document(text=new_text, cursor_position=new_cursor_position) + + def empty_line_count_at_the_end(self) -> int: + """ + Return number of empty lines at the end of the document. + """ + count = 0 + for line in self.lines[::-1]: + if not line or line.isspace(): + count += 1 + else: + break + + return count + + def start_of_paragraph(self, count: int = 1, before: bool = False) -> int: + """ + Return the start of the current paragraph. (Relative cursor position.) + """ + + def match_func(text: str) -> bool: + return not text or text.isspace() + + line_index = self.find_previous_matching_line( + match_func=match_func, count=count + ) + + if line_index: + add = 0 if before else 1 + return min(0, self.get_cursor_up_position(count=-line_index) + add) + else: + return -self.cursor_position + + def end_of_paragraph(self, count: int = 1, after: bool = False) -> int: + """ + Return the end of the current paragraph. (Relative cursor position.) + """ + + def match_func(text: str) -> bool: + return not text or text.isspace() + + line_index = self.find_next_matching_line(match_func=match_func, count=count) + + if line_index: + add = 0 if after else 1 + return max(0, self.get_cursor_down_position(count=line_index) - add) + else: + return len(self.text_after_cursor) + + # Modifiers. + + def insert_after(self, text: str) -> Document: + """ + Create a new document, with this text inserted after the buffer. + It keeps selection ranges and cursor position in sync. + """ + return Document( + text=self.text + text, + cursor_position=self.cursor_position, + selection=self.selection, + ) + + def insert_before(self, text: str) -> Document: + """ + Create a new document, with this text inserted before the buffer. + It keeps selection ranges and cursor position in sync. + """ + selection_state = self.selection + + if selection_state: + selection_state = SelectionState( + original_cursor_position=selection_state.original_cursor_position + + len(text), + type=selection_state.type, + ) + + return Document( + text=text + self.text, + cursor_position=self.cursor_position + len(text), + selection=selection_state, + ) diff --git a/src/prompt_toolkit/enums.py b/src/prompt_toolkit/enums.py new file mode 100644 index 0000000..da03633 --- /dev/null +++ b/src/prompt_toolkit/enums.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from enum import Enum + + +class EditingMode(Enum): + # The set of key bindings that is active. + VI = "VI" + EMACS = "EMACS" + + +#: Name of the search buffer. +SEARCH_BUFFER = "SEARCH_BUFFER" + +#: Name of the default buffer. +DEFAULT_BUFFER = "DEFAULT_BUFFER" + +#: Name of the system buffer. +SYSTEM_BUFFER = "SYSTEM_BUFFER" diff --git a/src/prompt_toolkit/eventloop/__init__.py b/src/prompt_toolkit/eventloop/__init__.py new file mode 100644 index 0000000..5df623b --- /dev/null +++ b/src/prompt_toolkit/eventloop/__init__.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from .async_generator import aclosing, generator_to_async_generator +from .inputhook import ( + InputHook, + InputHookContext, + InputHookSelector, + new_eventloop_with_inputhook, + set_eventloop_with_inputhook, +) +from .utils import ( + call_soon_threadsafe, + get_traceback_from_context, + run_in_executor_with_context, +) + +__all__ = [ + # Async generator + "generator_to_async_generator", + "aclosing", + # Utils. + "run_in_executor_with_context", + "call_soon_threadsafe", + "get_traceback_from_context", + # Inputhooks. + "InputHook", + "new_eventloop_with_inputhook", + "set_eventloop_with_inputhook", + "InputHookSelector", + "InputHookContext", +] diff --git a/src/prompt_toolkit/eventloop/async_generator.py b/src/prompt_toolkit/eventloop/async_generator.py new file mode 100644 index 0000000..5aee50a --- /dev/null +++ b/src/prompt_toolkit/eventloop/async_generator.py @@ -0,0 +1,124 @@ +""" +Implementation for async generators. +""" +from __future__ import annotations + +from asyncio import get_running_loop +from contextlib import asynccontextmanager +from queue import Empty, Full, Queue +from typing import Any, AsyncGenerator, Callable, Iterable, TypeVar + +from .utils import run_in_executor_with_context + +__all__ = [ + "aclosing", + "generator_to_async_generator", +] + +_T_Generator = TypeVar("_T_Generator", bound=AsyncGenerator[Any, None]) + + +@asynccontextmanager +async def aclosing( + thing: _T_Generator, +) -> AsyncGenerator[_T_Generator, None]: + "Similar to `contextlib.aclosing`, in Python 3.10." + try: + yield thing + finally: + await thing.aclose() + + +# By default, choose a buffer size that's a good balance between having enough +# throughput, but not consuming too much memory. We use this to consume a sync +# generator of completions as an async generator. If the queue size is very +# small (like 1), consuming the completions goes really slow (when there are a +# lot of items). If the queue size would be unlimited or too big, this can +# cause overconsumption of memory, and cause CPU time spent producing items +# that are no longer needed (if the consumption of the async generator stops at +# some point). We need a fixed size in order to get some back pressure from the +# async consumer to the sync producer. We choose 1000 by default here. If we +# have around 50k completions, measurements show that 1000 is still +# significantly faster than a buffer of 100. +DEFAULT_BUFFER_SIZE: int = 1000 + +_T = TypeVar("_T") + + +class _Done: + pass + + +async def generator_to_async_generator( + get_iterable: Callable[[], Iterable[_T]], + buffer_size: int = DEFAULT_BUFFER_SIZE, +) -> AsyncGenerator[_T, None]: + """ + Turn a generator or iterable into an async generator. + + This works by running the generator in a background thread. + + :param get_iterable: Function that returns a generator or iterable when + called. + :param buffer_size: Size of the queue between the async consumer and the + synchronous generator that produces items. + """ + quitting = False + # NOTE: We are limiting the queue size in order to have back-pressure. + q: Queue[_T | _Done] = Queue(maxsize=buffer_size) + loop = get_running_loop() + + def runner() -> None: + """ + Consume the generator in background thread. + When items are received, they'll be pushed to the queue. + """ + try: + for item in get_iterable(): + # When this async generator was cancelled (closed), stop this + # thread. + if quitting: + return + + while True: + try: + q.put(item, timeout=1) + except Full: + if quitting: + return + continue + else: + break + + finally: + while True: + try: + q.put(_Done(), timeout=1) + except Full: + if quitting: + return + continue + else: + break + + # Start background thread. + runner_f = run_in_executor_with_context(runner) + + try: + while True: + try: + item = q.get_nowait() + except Empty: + item = await loop.run_in_executor(None, q.get) + if isinstance(item, _Done): + break + else: + yield item + finally: + # When this async generator is closed (GeneratorExit exception, stop + # the background thread as well. - we don't need that anymore.) + quitting = True + + # Wait for the background thread to finish. (should happen right after + # the last item is yielded). + await runner_f diff --git a/src/prompt_toolkit/eventloop/inputhook.py b/src/prompt_toolkit/eventloop/inputhook.py new file mode 100644 index 0000000..a4c0eee --- /dev/null +++ b/src/prompt_toolkit/eventloop/inputhook.py @@ -0,0 +1,190 @@ +""" +Similar to `PyOS_InputHook` of the Python API, we can plug in an input hook in +the asyncio event loop. + +The way this works is by using a custom 'selector' that runs the other event +loop until the real selector is ready. + +It's the responsibility of this event hook to return when there is input ready. +There are two ways to detect when input is ready: + +The inputhook itself is a callable that receives an `InputHookContext`. This +callable should run the other event loop, and return when the main loop has +stuff to do. There are two ways to detect when to return: + +- Call the `input_is_ready` method periodically. Quit when this returns `True`. + +- Add the `fileno` as a watch to the external eventloop. Quit when file descriptor + becomes readable. (But don't read from it.) + + Note that this is not the same as checking for `sys.stdin.fileno()`. The + eventloop of prompt-toolkit allows thread-based executors, for example for + asynchronous autocompletion. When the completion for instance is ready, we + also want prompt-toolkit to gain control again in order to display that. +""" +from __future__ import annotations + +import asyncio +import os +import select +import selectors +import sys +import threading +from asyncio import AbstractEventLoop, get_running_loop +from selectors import BaseSelector, SelectorKey +from typing import TYPE_CHECKING, Any, Callable, Mapping + +__all__ = [ + "new_eventloop_with_inputhook", + "set_eventloop_with_inputhook", + "InputHookSelector", + "InputHookContext", + "InputHook", +] + +if TYPE_CHECKING: + from _typeshed import FileDescriptorLike + from typing_extensions import TypeAlias + + _EventMask = int + + +class InputHookContext: + """ + Given as a parameter to the inputhook. + """ + + def __init__(self, fileno: int, input_is_ready: Callable[[], bool]) -> None: + self._fileno = fileno + self.input_is_ready = input_is_ready + + def fileno(self) -> int: + return self._fileno + + +InputHook: TypeAlias = Callable[[InputHookContext], None] + + +def new_eventloop_with_inputhook( + inputhook: Callable[[InputHookContext], None], +) -> AbstractEventLoop: + """ + Create a new event loop with the given inputhook. + """ + selector = InputHookSelector(selectors.DefaultSelector(), inputhook) + loop = asyncio.SelectorEventLoop(selector) + return loop + + +def set_eventloop_with_inputhook( + inputhook: Callable[[InputHookContext], None], +) -> AbstractEventLoop: + """ + Create a new event loop with the given inputhook, and activate it. + """ + # Deprecated! + + loop = new_eventloop_with_inputhook(inputhook) + asyncio.set_event_loop(loop) + return loop + + +class InputHookSelector(BaseSelector): + """ + Usage: + + selector = selectors.SelectSelector() + loop = asyncio.SelectorEventLoop(InputHookSelector(selector, inputhook)) + asyncio.set_event_loop(loop) + """ + + def __init__( + self, selector: BaseSelector, inputhook: Callable[[InputHookContext], None] + ) -> None: + self.selector = selector + self.inputhook = inputhook + self._r, self._w = os.pipe() + + def register( + self, fileobj: FileDescriptorLike, events: _EventMask, data: Any = None + ) -> SelectorKey: + return self.selector.register(fileobj, events, data=data) + + def unregister(self, fileobj: FileDescriptorLike) -> SelectorKey: + return self.selector.unregister(fileobj) + + def modify( + self, fileobj: FileDescriptorLike, events: _EventMask, data: Any = None + ) -> SelectorKey: + return self.selector.modify(fileobj, events, data=None) + + def select( + self, timeout: float | None = None + ) -> list[tuple[SelectorKey, _EventMask]]: + # If there are tasks in the current event loop, + # don't run the input hook. + if len(getattr(get_running_loop(), "_ready", [])) > 0: + return self.selector.select(timeout=timeout) + + ready = False + result = None + + # Run selector in other thread. + def run_selector() -> None: + nonlocal ready, result + result = self.selector.select(timeout=timeout) + os.write(self._w, b"x") + ready = True + + th = threading.Thread(target=run_selector) + th.start() + + def input_is_ready() -> bool: + return ready + + # Call inputhook. + # The inputhook function is supposed to return when our selector + # becomes ready. The inputhook can do that by registering the fd in its + # own loop, or by checking the `input_is_ready` function regularly. + self.inputhook(InputHookContext(self._r, input_is_ready)) + + # Flush the read end of the pipe. + try: + # Before calling 'os.read', call select.select. This is required + # when the gevent monkey patch has been applied. 'os.read' is never + # monkey patched and won't be cooperative, so that would block all + # other select() calls otherwise. + # See: http://www.gevent.org/gevent.os.html + + # Note: On Windows, this is apparently not an issue. + # However, if we would ever want to add a select call, it + # should use `windll.kernel32.WaitForMultipleObjects`, + # because `select.select` can't wait for a pipe on Windows. + if sys.platform != "win32": + select.select([self._r], [], [], None) + + os.read(self._r, 1024) + except OSError: + # This happens when the window resizes and a SIGWINCH was received. + # We get 'Error: [Errno 4] Interrupted system call' + # Just ignore. + pass + + # Wait for the real selector to be done. + th.join() + assert result is not None + return result + + def close(self) -> None: + """ + Clean up resources. + """ + if self._r: + os.close(self._r) + os.close(self._w) + + self._r = self._w = -1 + self.selector.close() + + def get_map(self) -> Mapping[FileDescriptorLike, SelectorKey]: + return self.selector.get_map() diff --git a/src/prompt_toolkit/eventloop/utils.py b/src/prompt_toolkit/eventloop/utils.py new file mode 100644 index 0000000..3138361 --- /dev/null +++ b/src/prompt_toolkit/eventloop/utils.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import asyncio +import contextvars +import sys +import time +from asyncio import get_running_loop +from types import TracebackType +from typing import Any, Awaitable, Callable, TypeVar, cast + +__all__ = [ + "run_in_executor_with_context", + "call_soon_threadsafe", + "get_traceback_from_context", +] + +_T = TypeVar("_T") + + +def run_in_executor_with_context( + func: Callable[..., _T], + *args: Any, + loop: asyncio.AbstractEventLoop | None = None, +) -> Awaitable[_T]: + """ + Run a function in an executor, but make sure it uses the same contextvars. + This is required so that the function will see the right application. + + See also: https://bugs.python.org/issue34014 + """ + loop = loop or get_running_loop() + ctx: contextvars.Context = contextvars.copy_context() + + return loop.run_in_executor(None, ctx.run, func, *args) + + +def call_soon_threadsafe( + func: Callable[[], None], + max_postpone_time: float | None = None, + loop: asyncio.AbstractEventLoop | None = None, +) -> None: + """ + Wrapper around asyncio's `call_soon_threadsafe`. + + This takes a `max_postpone_time` which can be used to tune the urgency of + the method. + + Asyncio runs tasks in first-in-first-out. However, this is not what we + want for the render function of the prompt_toolkit UI. Rendering is + expensive, but since the UI is invalidated very often, in some situations + we render the UI too often, so much that the rendering CPU usage slows down + the rest of the processing of the application. (Pymux is an example where + we have to balance the CPU time spend on rendering the UI, and parsing + process output.) + However, we want to set a deadline value, for when the rendering should + happen. (The UI should stay responsive). + """ + loop2 = loop or get_running_loop() + + # If no `max_postpone_time` has been given, schedule right now. + if max_postpone_time is None: + loop2.call_soon_threadsafe(func) + return + + max_postpone_until = time.time() + max_postpone_time + + def schedule() -> None: + # When there are no other tasks scheduled in the event loop. Run it + # now. + # Notice: uvloop doesn't have this _ready attribute. In that case, + # always call immediately. + if not getattr(loop2, "_ready", []): + func() + return + + # If the timeout expired, run this now. + if time.time() > max_postpone_until: + func() + return + + # Schedule again for later. + loop2.call_soon_threadsafe(schedule) + + loop2.call_soon_threadsafe(schedule) + + +def get_traceback_from_context(context: dict[str, Any]) -> TracebackType | None: + """ + Get the traceback object from the context. + """ + exception = context.get("exception") + if exception: + if hasattr(exception, "__traceback__"): + return cast(TracebackType, exception.__traceback__) + else: + # call_exception_handler() is usually called indirectly + # from an except block. If it's not the case, the traceback + # is undefined... + return sys.exc_info()[2] + + return None diff --git a/src/prompt_toolkit/eventloop/win32.py b/src/prompt_toolkit/eventloop/win32.py new file mode 100644 index 0000000..56a0c7d --- /dev/null +++ b/src/prompt_toolkit/eventloop/win32.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import sys + +assert sys.platform == "win32" + +from ctypes import pointer + +from ..utils import SPHINX_AUTODOC_RUNNING + +# Do not import win32-specific stuff when generating documentation. +# Otherwise RTD would be unable to generate docs for this module. +if not SPHINX_AUTODOC_RUNNING: + from ctypes import windll + +from ctypes.wintypes import BOOL, DWORD, HANDLE + +from prompt_toolkit.win32_types import SECURITY_ATTRIBUTES + +__all__ = ["wait_for_handles", "create_win32_event"] + + +WAIT_TIMEOUT = 0x00000102 +INFINITE = -1 + + +def wait_for_handles(handles: list[HANDLE], timeout: int = INFINITE) -> HANDLE | None: + """ + Waits for multiple handles. (Similar to 'select') Returns the handle which is ready. + Returns `None` on timeout. + http://msdn.microsoft.com/en-us/library/windows/desktop/ms687025(v=vs.85).aspx + + Note that handles should be a list of `HANDLE` objects, not integers. See + this comment in the patch by @quark-zju for the reason why: + + ''' Make sure HANDLE on Windows has a correct size + + Previously, the type of various HANDLEs are native Python integer + types. The ctypes library will treat them as 4-byte integer when used + in function arguments. On 64-bit Windows, HANDLE is 8-byte and usually + a small integer. Depending on whether the extra 4 bytes are zero-ed out + or not, things can happen to work, or break. ''' + + This function returns either `None` or one of the given `HANDLE` objects. + (The return value can be tested with the `is` operator.) + """ + arrtype = HANDLE * len(handles) + handle_array = arrtype(*handles) + + ret: int = windll.kernel32.WaitForMultipleObjects( + len(handle_array), handle_array, BOOL(False), DWORD(timeout) + ) + + if ret == WAIT_TIMEOUT: + return None + else: + return handles[ret] + + +def create_win32_event() -> HANDLE: + """ + Creates a Win32 unnamed Event . + http://msdn.microsoft.com/en-us/library/windows/desktop/ms682396(v=vs.85).aspx + """ + return HANDLE( + windll.kernel32.CreateEventA( + pointer(SECURITY_ATTRIBUTES()), + BOOL(True), # Manual reset event. + BOOL(False), # Initial state. + None, # Unnamed event object. + ) + ) diff --git a/src/prompt_toolkit/filters/__init__.py b/src/prompt_toolkit/filters/__init__.py new file mode 100644 index 0000000..277f428 --- /dev/null +++ b/src/prompt_toolkit/filters/__init__.py @@ -0,0 +1,70 @@ +""" +Filters decide whether something is active or not (they decide about a boolean +state). This is used to enable/disable features, like key bindings, parts of +the layout and other stuff. For instance, we could have a `HasSearch` filter +attached to some part of the layout, in order to show that part of the user +interface only while the user is searching. + +Filters are made to avoid having to attach callbacks to all event in order to +propagate state. However, they are lazy, they don't automatically propagate the +state of what they are observing. Only when a filter is called (it's actually a +callable), it will calculate its value. So, its not really reactive +programming, but it's made to fit for this framework. + +Filters can be chained using ``&`` and ``|`` operations, and inverted using the +``~`` operator, for instance:: + + filter = has_focus('default') & ~ has_selection +""" +from __future__ import annotations + +from .app import * +from .base import Always, Condition, Filter, FilterOrBool, Never +from .cli import * +from .utils import is_true, to_filter + +__all__ = [ + # app + "has_arg", + "has_completions", + "completion_is_selected", + "has_focus", + "buffer_has_focus", + "has_selection", + "has_validation_error", + "is_done", + "is_read_only", + "is_multiline", + "renderer_height_is_known", + "in_editing_mode", + "in_paste_mode", + "vi_mode", + "vi_navigation_mode", + "vi_insert_mode", + "vi_insert_multiple_mode", + "vi_replace_mode", + "vi_selection_mode", + "vi_waiting_for_text_object_mode", + "vi_digraph_mode", + "vi_recording_macro", + "emacs_mode", + "emacs_insert_mode", + "emacs_selection_mode", + "shift_selection_mode", + "is_searching", + "control_is_searchable", + "vi_search_direction_reversed", + # base. + "Filter", + "Never", + "Always", + "Condition", + "FilterOrBool", + # utils. + "is_true", + "to_filter", +] + +from .cli import __all__ as cli_all + +__all__.extend(cli_all) diff --git a/src/prompt_toolkit/filters/app.py b/src/prompt_toolkit/filters/app.py new file mode 100644 index 0000000..aacb228 --- /dev/null +++ b/src/prompt_toolkit/filters/app.py @@ -0,0 +1,418 @@ +""" +Filters that accept a `Application` as argument. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from prompt_toolkit.application.current import get_app +from prompt_toolkit.cache import memoized +from prompt_toolkit.enums import EditingMode + +from .base import Condition + +if TYPE_CHECKING: + from prompt_toolkit.layout.layout import FocusableElement + + +__all__ = [ + "has_arg", + "has_completions", + "completion_is_selected", + "has_focus", + "buffer_has_focus", + "has_selection", + "has_suggestion", + "has_validation_error", + "is_done", + "is_read_only", + "is_multiline", + "renderer_height_is_known", + "in_editing_mode", + "in_paste_mode", + "vi_mode", + "vi_navigation_mode", + "vi_insert_mode", + "vi_insert_multiple_mode", + "vi_replace_mode", + "vi_selection_mode", + "vi_waiting_for_text_object_mode", + "vi_digraph_mode", + "vi_recording_macro", + "emacs_mode", + "emacs_insert_mode", + "emacs_selection_mode", + "shift_selection_mode", + "is_searching", + "control_is_searchable", + "vi_search_direction_reversed", +] + + +# NOTE: `has_focus` below should *not* be `memoized`. It can reference any user +# control. For instance, if we would continuously create new +# `PromptSession` instances, then previous instances won't be released, +# because this memoize (which caches results in the global scope) will +# still refer to each instance. +def has_focus(value: FocusableElement) -> Condition: + """ + Enable when this buffer has the focus. + """ + from prompt_toolkit.buffer import Buffer + from prompt_toolkit.layout import walk + from prompt_toolkit.layout.containers import Container, Window, to_container + from prompt_toolkit.layout.controls import UIControl + + if isinstance(value, str): + + def test() -> bool: + return get_app().current_buffer.name == value + + elif isinstance(value, Buffer): + + def test() -> bool: + return get_app().current_buffer == value + + elif isinstance(value, UIControl): + + def test() -> bool: + return get_app().layout.current_control == value + + else: + value = to_container(value) + + if isinstance(value, Window): + + def test() -> bool: + return get_app().layout.current_window == value + + else: + + def test() -> bool: + # Consider focused when any window inside this container is + # focused. + current_window = get_app().layout.current_window + + for c in walk(cast(Container, value)): + if isinstance(c, Window) and c == current_window: + return True + return False + + @Condition + def has_focus_filter() -> bool: + return test() + + return has_focus_filter + + +@Condition +def buffer_has_focus() -> bool: + """ + Enabled when the currently focused control is a `BufferControl`. + """ + return get_app().layout.buffer_has_focus + + +@Condition +def has_selection() -> bool: + """ + Enable when the current buffer has a selection. + """ + return bool(get_app().current_buffer.selection_state) + + +@Condition +def has_suggestion() -> bool: + """ + Enable when the current buffer has a suggestion. + """ + buffer = get_app().current_buffer + return buffer.suggestion is not None and buffer.suggestion.text != "" + + +@Condition +def has_completions() -> bool: + """ + Enable when the current buffer has completions. + """ + state = get_app().current_buffer.complete_state + return state is not None and len(state.completions) > 0 + + +@Condition +def completion_is_selected() -> bool: + """ + True when the user selected a completion. + """ + complete_state = get_app().current_buffer.complete_state + return complete_state is not None and complete_state.current_completion is not None + + +@Condition +def is_read_only() -> bool: + """ + True when the current buffer is read only. + """ + return get_app().current_buffer.read_only() + + +@Condition +def is_multiline() -> bool: + """ + True when the current buffer has been marked as multiline. + """ + return get_app().current_buffer.multiline() + + +@Condition +def has_validation_error() -> bool: + "Current buffer has validation error." + return get_app().current_buffer.validation_error is not None + + +@Condition +def has_arg() -> bool: + "Enable when the input processor has an 'arg'." + return get_app().key_processor.arg is not None + + +@Condition +def is_done() -> bool: + """ + True when the CLI is returning, aborting or exiting. + """ + return get_app().is_done + + +@Condition +def renderer_height_is_known() -> bool: + """ + Only True when the renderer knows it's real height. + + (On VT100 terminals, we have to wait for a CPR response, before we can be + sure of the available height between the cursor position and the bottom of + the terminal. And usually it's nicer to wait with drawing bottom toolbars + until we receive the height, in order to avoid flickering -- first drawing + somewhere in the middle, and then again at the bottom.) + """ + return get_app().renderer.height_is_known + + +@memoized() +def in_editing_mode(editing_mode: EditingMode) -> Condition: + """ + Check whether a given editing mode is active. (Vi or Emacs.) + """ + + @Condition + def in_editing_mode_filter() -> bool: + return get_app().editing_mode == editing_mode + + return in_editing_mode_filter + + +@Condition +def in_paste_mode() -> bool: + return get_app().paste_mode() + + +@Condition +def vi_mode() -> bool: + return get_app().editing_mode == EditingMode.VI + + +@Condition +def vi_navigation_mode() -> bool: + """ + Active when the set for Vi navigation key bindings are active. + """ + from prompt_toolkit.key_binding.vi_state import InputMode + + app = get_app() + + if ( + app.editing_mode != EditingMode.VI + or app.vi_state.operator_func + or app.vi_state.waiting_for_digraph + or app.current_buffer.selection_state + ): + return False + + return ( + app.vi_state.input_mode == InputMode.NAVIGATION + or app.vi_state.temporary_navigation_mode + or app.current_buffer.read_only() + ) + + +@Condition +def vi_insert_mode() -> bool: + from prompt_toolkit.key_binding.vi_state import InputMode + + app = get_app() + + if ( + app.editing_mode != EditingMode.VI + or app.vi_state.operator_func + or app.vi_state.waiting_for_digraph + or app.current_buffer.selection_state + or app.vi_state.temporary_navigation_mode + or app.current_buffer.read_only() + ): + return False + + return app.vi_state.input_mode == InputMode.INSERT + + +@Condition +def vi_insert_multiple_mode() -> bool: + from prompt_toolkit.key_binding.vi_state import InputMode + + app = get_app() + + if ( + app.editing_mode != EditingMode.VI + or app.vi_state.operator_func + or app.vi_state.waiting_for_digraph + or app.current_buffer.selection_state + or app.vi_state.temporary_navigation_mode + or app.current_buffer.read_only() + ): + return False + + return app.vi_state.input_mode == InputMode.INSERT_MULTIPLE + + +@Condition +def vi_replace_mode() -> bool: + from prompt_toolkit.key_binding.vi_state import InputMode + + app = get_app() + + if ( + app.editing_mode != EditingMode.VI + or app.vi_state.operator_func + or app.vi_state.waiting_for_digraph + or app.current_buffer.selection_state + or app.vi_state.temporary_navigation_mode + or app.current_buffer.read_only() + ): + return False + + return app.vi_state.input_mode == InputMode.REPLACE + + +@Condition +def vi_replace_single_mode() -> bool: + from prompt_toolkit.key_binding.vi_state import InputMode + + app = get_app() + + if ( + app.editing_mode != EditingMode.VI + or app.vi_state.operator_func + or app.vi_state.waiting_for_digraph + or app.current_buffer.selection_state + or app.vi_state.temporary_navigation_mode + or app.current_buffer.read_only() + ): + return False + + return app.vi_state.input_mode == InputMode.REPLACE_SINGLE + + +@Condition +def vi_selection_mode() -> bool: + app = get_app() + if app.editing_mode != EditingMode.VI: + return False + + return bool(app.current_buffer.selection_state) + + +@Condition +def vi_waiting_for_text_object_mode() -> bool: + app = get_app() + if app.editing_mode != EditingMode.VI: + return False + + return app.vi_state.operator_func is not None + + +@Condition +def vi_digraph_mode() -> bool: + app = get_app() + if app.editing_mode != EditingMode.VI: + return False + + return app.vi_state.waiting_for_digraph + + +@Condition +def vi_recording_macro() -> bool: + "When recording a Vi macro." + app = get_app() + if app.editing_mode != EditingMode.VI: + return False + + return app.vi_state.recording_register is not None + + +@Condition +def emacs_mode() -> bool: + "When the Emacs bindings are active." + return get_app().editing_mode == EditingMode.EMACS + + +@Condition +def emacs_insert_mode() -> bool: + app = get_app() + if ( + app.editing_mode != EditingMode.EMACS + or app.current_buffer.selection_state + or app.current_buffer.read_only() + ): + return False + return True + + +@Condition +def emacs_selection_mode() -> bool: + app = get_app() + return bool( + app.editing_mode == EditingMode.EMACS and app.current_buffer.selection_state + ) + + +@Condition +def shift_selection_mode() -> bool: + app = get_app() + return bool( + app.current_buffer.selection_state + and app.current_buffer.selection_state.shift_mode + ) + + +@Condition +def is_searching() -> bool: + "When we are searching." + app = get_app() + return app.layout.is_searching + + +@Condition +def control_is_searchable() -> bool: + "When the current UIControl is searchable." + from prompt_toolkit.layout.controls import BufferControl + + control = get_app().layout.current_control + + return ( + isinstance(control, BufferControl) and control.search_buffer_control is not None + ) + + +@Condition +def vi_search_direction_reversed() -> bool: + "When the '/' and '?' key bindings for Vi-style searching have been reversed." + return get_app().reverse_vi_search_direction() diff --git a/src/prompt_toolkit/filters/base.py b/src/prompt_toolkit/filters/base.py new file mode 100644 index 0000000..afce6dc --- /dev/null +++ b/src/prompt_toolkit/filters/base.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from typing import Callable, Iterable, Union + +__all__ = ["Filter", "Never", "Always", "Condition", "FilterOrBool"] + + +class Filter(metaclass=ABCMeta): + """ + Base class for any filter to activate/deactivate a feature, depending on a + condition. + + The return value of ``__call__`` will tell if the feature should be active. + """ + + def __init__(self) -> None: + self._and_cache: dict[Filter, Filter] = {} + self._or_cache: dict[Filter, Filter] = {} + self._invert_result: Filter | None = None + + @abstractmethod + def __call__(self) -> bool: + """ + The actual call to evaluate the filter. + """ + return True + + def __and__(self, other: Filter) -> Filter: + """ + Chaining of filters using the & operator. + """ + assert isinstance(other, Filter), "Expecting filter, got %r" % other + + if isinstance(other, Always): + return self + if isinstance(other, Never): + return other + + if other in self._and_cache: + return self._and_cache[other] + + result = _AndList.create([self, other]) + self._and_cache[other] = result + return result + + def __or__(self, other: Filter) -> Filter: + """ + Chaining of filters using the | operator. + """ + assert isinstance(other, Filter), "Expecting filter, got %r" % other + + if isinstance(other, Always): + return other + if isinstance(other, Never): + return self + + if other in self._or_cache: + return self._or_cache[other] + + result = _OrList.create([self, other]) + self._or_cache[other] = result + return result + + def __invert__(self) -> Filter: + """ + Inverting of filters using the ~ operator. + """ + if self._invert_result is None: + self._invert_result = _Invert(self) + + return self._invert_result + + def __bool__(self) -> None: + """ + By purpose, we don't allow bool(...) operations directly on a filter, + because the meaning is ambiguous. + + Executing a filter has to be done always by calling it. Providing + defaults for `None` values should be done through an `is None` check + instead of for instance ``filter1 or Always()``. + """ + raise ValueError( + "The truth value of a Filter is ambiguous. " + "Instead, call it as a function." + ) + + +def _remove_duplicates(filters: list[Filter]) -> list[Filter]: + result = [] + for f in filters: + if f not in result: + result.append(f) + return result + + +class _AndList(Filter): + """ + Result of &-operation between several filters. + """ + + def __init__(self, filters: list[Filter]) -> None: + super().__init__() + self.filters = filters + + @classmethod + def create(cls, filters: Iterable[Filter]) -> Filter: + """ + Create a new filter by applying an `&` operator between them. + + If there's only one unique filter in the given iterable, it will return + that one filter instead of an `_AndList`. + """ + filters_2: list[Filter] = [] + + for f in filters: + if isinstance(f, _AndList): # Turn nested _AndLists into one. + filters_2.extend(f.filters) + else: + filters_2.append(f) + + # Remove duplicates. This could speed up execution, and doesn't make a + # difference for the evaluation. + filters = _remove_duplicates(filters_2) + + # If only one filter is left, return that without wrapping into an + # `_AndList`. + if len(filters) == 1: + return filters[0] + + return cls(filters) + + def __call__(self) -> bool: + return all(f() for f in self.filters) + + def __repr__(self) -> str: + return "&".join(repr(f) for f in self.filters) + + +class _OrList(Filter): + """ + Result of |-operation between several filters. + """ + + def __init__(self, filters: list[Filter]) -> None: + super().__init__() + self.filters = filters + + @classmethod + def create(cls, filters: Iterable[Filter]) -> Filter: + """ + Create a new filter by applying an `|` operator between them. + + If there's only one unique filter in the given iterable, it will return + that one filter instead of an `_OrList`. + """ + filters_2: list[Filter] = [] + + for f in filters: + if isinstance(f, _OrList): # Turn nested _AndLists into one. + filters_2.extend(f.filters) + else: + filters_2.append(f) + + # Remove duplicates. This could speed up execution, and doesn't make a + # difference for the evaluation. + filters = _remove_duplicates(filters_2) + + # If only one filter is left, return that without wrapping into an + # `_AndList`. + if len(filters) == 1: + return filters[0] + + return cls(filters) + + def __call__(self) -> bool: + return any(f() for f in self.filters) + + def __repr__(self) -> str: + return "|".join(repr(f) for f in self.filters) + + +class _Invert(Filter): + """ + Negation of another filter. + """ + + def __init__(self, filter: Filter) -> None: + super().__init__() + self.filter = filter + + def __call__(self) -> bool: + return not self.filter() + + def __repr__(self) -> str: + return "~%r" % self.filter + + +class Always(Filter): + """ + Always enable feature. + """ + + def __call__(self) -> bool: + return True + + def __or__(self, other: Filter) -> Filter: + return self + + def __invert__(self) -> Never: + return Never() + + +class Never(Filter): + """ + Never enable feature. + """ + + def __call__(self) -> bool: + return False + + def __and__(self, other: Filter) -> Filter: + return self + + def __invert__(self) -> Always: + return Always() + + +class Condition(Filter): + """ + Turn any callable into a Filter. The callable is supposed to not take any + arguments. + + This can be used as a decorator:: + + @Condition + def feature_is_active(): # `feature_is_active` becomes a Filter. + return True + + :param func: Callable which takes no inputs and returns a boolean. + """ + + def __init__(self, func: Callable[[], bool]) -> None: + super().__init__() + self.func = func + + def __call__(self) -> bool: + return self.func() + + def __repr__(self) -> str: + return "Condition(%r)" % self.func + + +# Often used as type annotation. +FilterOrBool = Union[Filter, bool] diff --git a/src/prompt_toolkit/filters/cli.py b/src/prompt_toolkit/filters/cli.py new file mode 100644 index 0000000..c95080a --- /dev/null +++ b/src/prompt_toolkit/filters/cli.py @@ -0,0 +1,64 @@ +""" +For backwards-compatibility. keep this file. +(Many people are going to have key bindings that rely on this file.) +""" +from __future__ import annotations + +from .app import * + +__all__ = [ + # Old names. + "HasArg", + "HasCompletions", + "HasFocus", + "HasSelection", + "HasValidationError", + "IsDone", + "IsReadOnly", + "IsMultiline", + "RendererHeightIsKnown", + "InEditingMode", + "InPasteMode", + "ViMode", + "ViNavigationMode", + "ViInsertMode", + "ViInsertMultipleMode", + "ViReplaceMode", + "ViSelectionMode", + "ViWaitingForTextObjectMode", + "ViDigraphMode", + "EmacsMode", + "EmacsInsertMode", + "EmacsSelectionMode", + "IsSearching", + "HasSearch", + "ControlIsSearchable", +] + +# Keep the original classnames for backwards compatibility. +HasValidationError = lambda: has_validation_error +HasArg = lambda: has_arg +IsDone = lambda: is_done +RendererHeightIsKnown = lambda: renderer_height_is_known +ViNavigationMode = lambda: vi_navigation_mode +InPasteMode = lambda: in_paste_mode +EmacsMode = lambda: emacs_mode +EmacsInsertMode = lambda: emacs_insert_mode +ViMode = lambda: vi_mode +IsSearching = lambda: is_searching +HasSearch = lambda: is_searching +ControlIsSearchable = lambda: control_is_searchable +EmacsSelectionMode = lambda: emacs_selection_mode +ViDigraphMode = lambda: vi_digraph_mode +ViWaitingForTextObjectMode = lambda: vi_waiting_for_text_object_mode +ViSelectionMode = lambda: vi_selection_mode +ViReplaceMode = lambda: vi_replace_mode +ViInsertMultipleMode = lambda: vi_insert_multiple_mode +ViInsertMode = lambda: vi_insert_mode +HasSelection = lambda: has_selection +HasCompletions = lambda: has_completions +IsReadOnly = lambda: is_read_only +IsMultiline = lambda: is_multiline + +HasFocus = has_focus # No lambda here! (Has_focus is callable that returns a callable.) +InEditingMode = in_editing_mode diff --git a/src/prompt_toolkit/filters/utils.py b/src/prompt_toolkit/filters/utils.py new file mode 100644 index 0000000..bac85ba --- /dev/null +++ b/src/prompt_toolkit/filters/utils.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from .base import Always, Filter, FilterOrBool, Never + +__all__ = [ + "to_filter", + "is_true", +] + + +_always = Always() +_never = Never() + + +_bool_to_filter: dict[bool, Filter] = { + True: _always, + False: _never, +} + + +def to_filter(bool_or_filter: FilterOrBool) -> Filter: + """ + Accept both booleans and Filters as input and + turn it into a Filter. + """ + if isinstance(bool_or_filter, bool): + return _bool_to_filter[bool_or_filter] + + if isinstance(bool_or_filter, Filter): + return bool_or_filter + + raise TypeError("Expecting a bool or a Filter instance. Got %r" % bool_or_filter) + + +def is_true(value: FilterOrBool) -> bool: + """ + Test whether `value` is True. In case of a Filter, call it. + + :param value: Boolean or `Filter` instance. + """ + return to_filter(value)() diff --git a/src/prompt_toolkit/formatted_text/__init__.py b/src/prompt_toolkit/formatted_text/__init__.py new file mode 100644 index 0000000..db44ab9 --- /dev/null +++ b/src/prompt_toolkit/formatted_text/__init__.py @@ -0,0 +1,58 @@ +""" +Many places in prompt_toolkit can take either plain text, or formatted text. +For instance the :func:`~prompt_toolkit.shortcuts.prompt` function takes either +plain text or formatted text for the prompt. The +:class:`~prompt_toolkit.layout.FormattedTextControl` can also take either plain +text or formatted text. + +In any case, there is an input that can either be just plain text (a string), +an :class:`.HTML` object, an :class:`.ANSI` object or a sequence of +`(style_string, text)` tuples. The :func:`.to_formatted_text` conversion +function takes any of these and turns all of them into such a tuple sequence. +""" +from __future__ import annotations + +from .ansi import ANSI +from .base import ( + AnyFormattedText, + FormattedText, + OneStyleAndTextTuple, + StyleAndTextTuples, + Template, + is_formatted_text, + merge_formatted_text, + to_formatted_text, +) +from .html import HTML +from .pygments import PygmentsTokens +from .utils import ( + fragment_list_len, + fragment_list_to_text, + fragment_list_width, + split_lines, + to_plain_text, +) + +__all__ = [ + # Base. + "AnyFormattedText", + "OneStyleAndTextTuple", + "to_formatted_text", + "is_formatted_text", + "Template", + "merge_formatted_text", + "FormattedText", + "StyleAndTextTuples", + # HTML. + "HTML", + # ANSI. + "ANSI", + # Pygments. + "PygmentsTokens", + # Utils. + "fragment_list_len", + "fragment_list_width", + "fragment_list_to_text", + "split_lines", + "to_plain_text", +] diff --git a/src/prompt_toolkit/formatted_text/ansi.py b/src/prompt_toolkit/formatted_text/ansi.py new file mode 100644 index 0000000..08ec0b3 --- /dev/null +++ b/src/prompt_toolkit/formatted_text/ansi.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +from string import Formatter +from typing import Generator + +from prompt_toolkit.output.vt100 import BG_ANSI_COLORS, FG_ANSI_COLORS +from prompt_toolkit.output.vt100 import _256_colors as _256_colors_table + +from .base import StyleAndTextTuples + +__all__ = [ + "ANSI", + "ansi_escape", +] + + +class ANSI: + """ + ANSI formatted text. + Take something ANSI escaped text, for use as a formatted string. E.g. + + :: + + ANSI('\\x1b[31mhello \\x1b[32mworld') + + Characters between ``\\001`` and ``\\002`` are supposed to have a zero width + when printed, but these are literally sent to the terminal output. This can + be used for instance, for inserting Final Term prompt commands. They will + be translated into a prompt_toolkit '[ZeroWidthEscape]' fragment. + """ + + def __init__(self, value: str) -> None: + self.value = value + self._formatted_text: StyleAndTextTuples = [] + + # Default style attributes. + self._color: str | None = None + self._bgcolor: str | None = None + self._bold = False + self._underline = False + self._strike = False + self._italic = False + self._blink = False + self._reverse = False + self._hidden = False + + # Process received text. + parser = self._parse_corot() + parser.send(None) # type: ignore + for c in value: + parser.send(c) + + def _parse_corot(self) -> Generator[None, str, None]: + """ + Coroutine that parses the ANSI escape sequences. + """ + style = "" + formatted_text = self._formatted_text + + while True: + # NOTE: CSI is a special token within a stream of characters that + # introduces an ANSI control sequence used to set the + # style attributes of the following characters. + csi = False + + c = yield + + # Everything between \001 and \002 should become a ZeroWidthEscape. + if c == "\001": + escaped_text = "" + while c != "\002": + c = yield + if c == "\002": + formatted_text.append(("[ZeroWidthEscape]", escaped_text)) + c = yield + break + else: + escaped_text += c + + # Check for CSI + if c == "\x1b": + # Start of color escape sequence. + square_bracket = yield + if square_bracket == "[": + csi = True + else: + continue + elif c == "\x9b": + csi = True + + if csi: + # Got a CSI sequence. Color codes are following. + current = "" + params = [] + + while True: + char = yield + + # Construct number + if char.isdigit(): + current += char + + # Eval number + else: + # Limit and save number value + params.append(min(int(current or 0), 9999)) + + # Get delimiter token if present + if char == ";": + current = "" + + # Check and evaluate color codes + elif char == "m": + # Set attributes and token. + self._select_graphic_rendition(params) + style = self._create_style_string() + break + + # Check and evaluate cursor forward + elif char == "C": + for i in range(params[0]): + # add <SPACE> using current style + formatted_text.append((style, " ")) + break + + else: + # Ignore unsupported sequence. + break + else: + # Add current character. + # NOTE: At this point, we could merge the current character + # into the previous tuple if the style did not change, + # however, it's not worth the effort given that it will + # be "Exploded" once again when it's rendered to the + # output. + formatted_text.append((style, c)) + + def _select_graphic_rendition(self, attrs: list[int]) -> None: + """ + Taken a list of graphics attributes and apply changes. + """ + if not attrs: + attrs = [0] + else: + attrs = list(attrs[::-1]) + + while attrs: + attr = attrs.pop() + + if attr in _fg_colors: + self._color = _fg_colors[attr] + elif attr in _bg_colors: + self._bgcolor = _bg_colors[attr] + elif attr == 1: + self._bold = True + # elif attr == 2: + # self._faint = True + elif attr == 3: + self._italic = True + elif attr == 4: + self._underline = True + elif attr == 5: + self._blink = True # Slow blink + elif attr == 6: + self._blink = True # Fast blink + elif attr == 7: + self._reverse = True + elif attr == 8: + self._hidden = True + elif attr == 9: + self._strike = True + elif attr == 22: + self._bold = False # Normal intensity + elif attr == 23: + self._italic = False + elif attr == 24: + self._underline = False + elif attr == 25: + self._blink = False + elif attr == 27: + self._reverse = False + elif attr == 28: + self._hidden = False + elif attr == 29: + self._strike = False + elif not attr: + # Reset all style attributes + self._color = None + self._bgcolor = None + self._bold = False + self._underline = False + self._strike = False + self._italic = False + self._blink = False + self._reverse = False + self._hidden = False + + elif attr in (38, 48) and len(attrs) > 1: + n = attrs.pop() + + # 256 colors. + if n == 5 and len(attrs) >= 1: + if attr == 38: + m = attrs.pop() + self._color = _256_colors.get(m) + elif attr == 48: + m = attrs.pop() + self._bgcolor = _256_colors.get(m) + + # True colors. + if n == 2 and len(attrs) >= 3: + try: + color_str = "#{:02x}{:02x}{:02x}".format( + attrs.pop(), + attrs.pop(), + attrs.pop(), + ) + except IndexError: + pass + else: + if attr == 38: + self._color = color_str + elif attr == 48: + self._bgcolor = color_str + + def _create_style_string(self) -> str: + """ + Turn current style flags into a string for usage in a formatted text. + """ + result = [] + if self._color: + result.append(self._color) + if self._bgcolor: + result.append("bg:" + self._bgcolor) + if self._bold: + result.append("bold") + if self._underline: + result.append("underline") + if self._strike: + result.append("strike") + if self._italic: + result.append("italic") + if self._blink: + result.append("blink") + if self._reverse: + result.append("reverse") + if self._hidden: + result.append("hidden") + + return " ".join(result) + + def __repr__(self) -> str: + return f"ANSI({self.value!r})" + + def __pt_formatted_text__(self) -> StyleAndTextTuples: + return self._formatted_text + + def format(self, *args: str, **kwargs: str) -> ANSI: + """ + Like `str.format`, but make sure that the arguments are properly + escaped. (No ANSI escapes can be injected.) + """ + return ANSI(FORMATTER.vformat(self.value, args, kwargs)) + + def __mod__(self, value: object) -> ANSI: + """ + ANSI('<b>%s</b>') % value + """ + if not isinstance(value, tuple): + value = (value,) + + value = tuple(ansi_escape(i) for i in value) + return ANSI(self.value % value) + + +# Mapping of the ANSI color codes to their names. +_fg_colors = {v: k for k, v in FG_ANSI_COLORS.items()} +_bg_colors = {v: k for k, v in BG_ANSI_COLORS.items()} + +# Mapping of the escape codes for 256colors to their 'ffffff' value. +_256_colors = {} + +for i, (r, g, b) in enumerate(_256_colors_table.colors): + _256_colors[i] = f"#{r:02x}{g:02x}{b:02x}" + + +def ansi_escape(text: object) -> str: + """ + Replace characters with a special meaning. + """ + return str(text).replace("\x1b", "?").replace("\b", "?") + + +class ANSIFormatter(Formatter): + def format_field(self, value: object, format_spec: str) -> str: + return ansi_escape(format(value, format_spec)) + + +FORMATTER = ANSIFormatter() diff --git a/src/prompt_toolkit/formatted_text/base.py b/src/prompt_toolkit/formatted_text/base.py new file mode 100644 index 0000000..92de353 --- /dev/null +++ b/src/prompt_toolkit/formatted_text/base.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable, Iterable, List, Tuple, Union, cast + +from prompt_toolkit.mouse_events import MouseEvent + +if TYPE_CHECKING: + from typing_extensions import Protocol + + from prompt_toolkit.key_binding.key_bindings import NotImplementedOrNone + +__all__ = [ + "OneStyleAndTextTuple", + "StyleAndTextTuples", + "MagicFormattedText", + "AnyFormattedText", + "to_formatted_text", + "is_formatted_text", + "Template", + "merge_formatted_text", + "FormattedText", +] + +OneStyleAndTextTuple = Union[ + Tuple[str, str], Tuple[str, str, Callable[[MouseEvent], "NotImplementedOrNone"]] +] + +# List of (style, text) tuples. +StyleAndTextTuples = List[OneStyleAndTextTuple] + + +if TYPE_CHECKING: + from typing_extensions import TypeGuard + + class MagicFormattedText(Protocol): + """ + Any object that implements ``__pt_formatted_text__`` represents formatted + text. + """ + + def __pt_formatted_text__(self) -> StyleAndTextTuples: + ... + + +AnyFormattedText = Union[ + str, + "MagicFormattedText", + StyleAndTextTuples, + # Callable[[], 'AnyFormattedText'] # Recursive definition not supported by mypy. + Callable[[], Any], + None, +] + + +def to_formatted_text( + value: AnyFormattedText, style: str = "", auto_convert: bool = False +) -> FormattedText: + """ + Convert the given value (which can be formatted text) into a list of text + fragments. (Which is the canonical form of formatted text.) The outcome is + always a `FormattedText` instance, which is a list of (style, text) tuples. + + It can take a plain text string, an `HTML` or `ANSI` object, anything that + implements `__pt_formatted_text__` or a callable that takes no arguments and + returns one of those. + + :param style: An additional style string which is applied to all text + fragments. + :param auto_convert: If `True`, also accept other types, and convert them + to a string first. + """ + result: FormattedText | StyleAndTextTuples + + if value is None: + result = [] + elif isinstance(value, str): + result = [("", value)] + elif isinstance(value, list): + result = value # StyleAndTextTuples + elif hasattr(value, "__pt_formatted_text__"): + result = cast("MagicFormattedText", value).__pt_formatted_text__() + elif callable(value): + return to_formatted_text(value(), style=style) + elif auto_convert: + result = [("", f"{value}")] + else: + raise ValueError( + "No formatted text. Expecting a unicode object, " + f"HTML, ANSI or a FormattedText instance. Got {value!r}" + ) + + # Apply extra style. + if style: + result = cast( + StyleAndTextTuples, + [(style + " " + item_style, *rest) for item_style, *rest in result], + ) + + # Make sure the result is wrapped in a `FormattedText`. Among other + # reasons, this is important for `print_formatted_text` to work correctly + # and distinguish between lists and formatted text. + if isinstance(result, FormattedText): + return result + else: + return FormattedText(result) + + +def is_formatted_text(value: object) -> TypeGuard[AnyFormattedText]: + """ + Check whether the input is valid formatted text (for use in assert + statements). + In case of a callable, it doesn't check the return type. + """ + if callable(value): + return True + if isinstance(value, (str, list)): + return True + if hasattr(value, "__pt_formatted_text__"): + return True + return False + + +class FormattedText(StyleAndTextTuples): + """ + A list of ``(style, text)`` tuples. + + (In some situations, this can also be ``(style, text, mouse_handler)`` + tuples.) + """ + + def __pt_formatted_text__(self) -> StyleAndTextTuples: + return self + + def __repr__(self) -> str: + return "FormattedText(%s)" % super().__repr__() + + +class Template: + """ + Template for string interpolation with formatted text. + + Example:: + + Template(' ... {} ... ').format(HTML(...)) + + :param text: Plain text. + """ + + def __init__(self, text: str) -> None: + assert "{0}" not in text + self.text = text + + def format(self, *values: AnyFormattedText) -> AnyFormattedText: + def get_result() -> AnyFormattedText: + # Split the template in parts. + parts = self.text.split("{}") + assert len(parts) - 1 == len(values) + + result = FormattedText() + for part, val in zip(parts, values): + result.append(("", part)) + result.extend(to_formatted_text(val)) + result.append(("", parts[-1])) + return result + + return get_result + + +def merge_formatted_text(items: Iterable[AnyFormattedText]) -> AnyFormattedText: + """ + Merge (Concatenate) several pieces of formatted text together. + """ + + def _merge_formatted_text() -> AnyFormattedText: + result = FormattedText() + for i in items: + result.extend(to_formatted_text(i)) + return result + + return _merge_formatted_text diff --git a/src/prompt_toolkit/formatted_text/html.py b/src/prompt_toolkit/formatted_text/html.py new file mode 100644 index 0000000..a940ac8 --- /dev/null +++ b/src/prompt_toolkit/formatted_text/html.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import xml.dom.minidom as minidom +from string import Formatter +from typing import Any + +from .base import FormattedText, StyleAndTextTuples + +__all__ = ["HTML"] + + +class HTML: + """ + HTML formatted text. + Take something HTML-like, for use as a formatted string. + + :: + + # Turn something into red. + HTML('<style fg="ansired" bg="#00ff44">...</style>') + + # Italic, bold, underline and strike. + HTML('<i>...</i>') + HTML('<b>...</b>') + HTML('<u>...</u>') + HTML('<s>...</s>') + + All HTML elements become available as a "class" in the style sheet. + E.g. ``<username>...</username>`` can be styled, by setting a style for + ``username``. + """ + + def __init__(self, value: str) -> None: + self.value = value + document = minidom.parseString(f"<html-root>{value}</html-root>") + + result: StyleAndTextTuples = [] + name_stack: list[str] = [] + fg_stack: list[str] = [] + bg_stack: list[str] = [] + + def get_current_style() -> str: + "Build style string for current node." + parts = [] + if name_stack: + parts.append("class:" + ",".join(name_stack)) + + if fg_stack: + parts.append("fg:" + fg_stack[-1]) + if bg_stack: + parts.append("bg:" + bg_stack[-1]) + return " ".join(parts) + + def process_node(node: Any) -> None: + "Process node recursively." + for child in node.childNodes: + if child.nodeType == child.TEXT_NODE: + result.append((get_current_style(), child.data)) + else: + add_to_name_stack = child.nodeName not in ( + "#document", + "html-root", + "style", + ) + fg = bg = "" + + for k, v in child.attributes.items(): + if k == "fg": + fg = v + if k == "bg": + bg = v + if k == "color": + fg = v # Alias for 'fg'. + + # Check for spaces in attributes. This would result in + # invalid style strings otherwise. + if " " in fg: + raise ValueError('"fg" attribute contains a space.') + if " " in bg: + raise ValueError('"bg" attribute contains a space.') + + if add_to_name_stack: + name_stack.append(child.nodeName) + if fg: + fg_stack.append(fg) + if bg: + bg_stack.append(bg) + + process_node(child) + + if add_to_name_stack: + name_stack.pop() + if fg: + fg_stack.pop() + if bg: + bg_stack.pop() + + process_node(document) + + self.formatted_text = FormattedText(result) + + def __repr__(self) -> str: + return f"HTML({self.value!r})" + + def __pt_formatted_text__(self) -> StyleAndTextTuples: + return self.formatted_text + + def format(self, *args: object, **kwargs: object) -> HTML: + """ + Like `str.format`, but make sure that the arguments are properly + escaped. + """ + return HTML(FORMATTER.vformat(self.value, args, kwargs)) + + def __mod__(self, value: object) -> HTML: + """ + HTML('<b>%s</b>') % value + """ + if not isinstance(value, tuple): + value = (value,) + + value = tuple(html_escape(i) for i in value) + return HTML(self.value % value) + + +class HTMLFormatter(Formatter): + def format_field(self, value: object, format_spec: str) -> str: + return html_escape(format(value, format_spec)) + + +def html_escape(text: object) -> str: + # The string interpolation functions also take integers and other types. + # Convert to string first. + if not isinstance(text, str): + text = f"{text}" + + return ( + text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + +FORMATTER = HTMLFormatter() diff --git a/src/prompt_toolkit/formatted_text/pygments.py b/src/prompt_toolkit/formatted_text/pygments.py new file mode 100644 index 0000000..d4ef3ad --- /dev/null +++ b/src/prompt_toolkit/formatted_text/pygments.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from prompt_toolkit.styles.pygments import pygments_token_to_classname + +from .base import StyleAndTextTuples + +if TYPE_CHECKING: + from pygments.token import Token + +__all__ = [ + "PygmentsTokens", +] + + +class PygmentsTokens: + """ + Turn a pygments token list into a list of prompt_toolkit text fragments + (``(style_str, text)`` tuples). + """ + + def __init__(self, token_list: list[tuple[Token, str]]) -> None: + self.token_list = token_list + + def __pt_formatted_text__(self) -> StyleAndTextTuples: + result: StyleAndTextTuples = [] + + for token, text in self.token_list: + result.append(("class:" + pygments_token_to_classname(token), text)) + + return result diff --git a/src/prompt_toolkit/formatted_text/utils.py b/src/prompt_toolkit/formatted_text/utils.py new file mode 100644 index 0000000..c8c37e0 --- /dev/null +++ b/src/prompt_toolkit/formatted_text/utils.py @@ -0,0 +1,102 @@ +""" +Utilities for manipulating formatted text. + +When ``to_formatted_text`` has been called, we get a list of ``(style, text)`` +tuples. This file contains functions for manipulating such a list. +""" +from __future__ import annotations + +from typing import Iterable, cast + +from prompt_toolkit.utils import get_cwidth + +from .base import ( + AnyFormattedText, + OneStyleAndTextTuple, + StyleAndTextTuples, + to_formatted_text, +) + +__all__ = [ + "to_plain_text", + "fragment_list_len", + "fragment_list_width", + "fragment_list_to_text", + "split_lines", +] + + +def to_plain_text(value: AnyFormattedText) -> str: + """ + Turn any kind of formatted text back into plain text. + """ + return fragment_list_to_text(to_formatted_text(value)) + + +def fragment_list_len(fragments: StyleAndTextTuples) -> int: + """ + Return the amount of characters in this text fragment list. + + :param fragments: List of ``(style_str, text)`` or + ``(style_str, text, mouse_handler)`` tuples. + """ + ZeroWidthEscape = "[ZeroWidthEscape]" + return sum(len(item[1]) for item in fragments if ZeroWidthEscape not in item[0]) + + +def fragment_list_width(fragments: StyleAndTextTuples) -> int: + """ + Return the character width of this text fragment list. + (Take double width characters into account.) + + :param fragments: List of ``(style_str, text)`` or + ``(style_str, text, mouse_handler)`` tuples. + """ + ZeroWidthEscape = "[ZeroWidthEscape]" + return sum( + get_cwidth(c) + for item in fragments + for c in item[1] + if ZeroWidthEscape not in item[0] + ) + + +def fragment_list_to_text(fragments: StyleAndTextTuples) -> str: + """ + Concatenate all the text parts again. + + :param fragments: List of ``(style_str, text)`` or + ``(style_str, text, mouse_handler)`` tuples. + """ + ZeroWidthEscape = "[ZeroWidthEscape]" + return "".join(item[1] for item in fragments if ZeroWidthEscape not in item[0]) + + +def split_lines( + fragments: Iterable[OneStyleAndTextTuple], +) -> Iterable[StyleAndTextTuples]: + """ + Take a single list of (style_str, text) tuples and yield one such list for each + line. Just like str.split, this will yield at least one item. + + :param fragments: Iterable of ``(style_str, text)`` or + ``(style_str, text, mouse_handler)`` tuples. + """ + line: StyleAndTextTuples = [] + + for style, string, *mouse_handler in fragments: + parts = string.split("\n") + + for part in parts[:-1]: + if part: + line.append(cast(OneStyleAndTextTuple, (style, part, *mouse_handler))) + yield line + line = [] + + line.append(cast(OneStyleAndTextTuple, (style, parts[-1], *mouse_handler))) + + # Always yield the last line, even when this is an empty line. This ensures + # that when `fragments` ends with a newline character, an additional empty + # line is yielded. (Otherwise, there's no way to differentiate between the + # cases where `fragments` does and doesn't end with a newline.) + yield line diff --git a/src/prompt_toolkit/history.py b/src/prompt_toolkit/history.py new file mode 100644 index 0000000..553918e --- /dev/null +++ b/src/prompt_toolkit/history.py @@ -0,0 +1,302 @@ +""" +Implementations for the history of a `Buffer`. + +NOTE: There is no `DynamicHistory`: + This doesn't work well, because the `Buffer` needs to be able to attach + an event handler to the event when a history entry is loaded. This + loading can be done asynchronously and making the history swappable would + probably break this. +""" +from __future__ import annotations + +import datetime +import os +import threading +from abc import ABCMeta, abstractmethod +from asyncio import get_running_loop +from typing import AsyncGenerator, Iterable, Sequence + +__all__ = [ + "History", + "ThreadedHistory", + "DummyHistory", + "FileHistory", + "InMemoryHistory", +] + + +class History(metaclass=ABCMeta): + """ + Base ``History`` class. + + This also includes abstract methods for loading/storing history. + """ + + def __init__(self) -> None: + # In memory storage for strings. + self._loaded = False + + # History that's loaded already, in reverse order. Latest, most recent + # item first. + self._loaded_strings: list[str] = [] + + # + # Methods expected by `Buffer`. + # + + async def load(self) -> AsyncGenerator[str, None]: + """ + Load the history and yield all the entries in reverse order (latest, + most recent history entry first). + + This method can be called multiple times from the `Buffer` to + repopulate the history when prompting for a new input. So we are + responsible here for both caching, and making sure that strings that + were were appended to the history will be incorporated next time this + method is called. + """ + if not self._loaded: + self._loaded_strings = list(self.load_history_strings()) + self._loaded = True + + for item in self._loaded_strings: + yield item + + def get_strings(self) -> list[str]: + """ + Get the strings from the history that are loaded so far. + (In order. Oldest item first.) + """ + return self._loaded_strings[::-1] + + def append_string(self, string: str) -> None: + "Add string to the history." + self._loaded_strings.insert(0, string) + self.store_string(string) + + # + # Implementation for specific backends. + # + + @abstractmethod + def load_history_strings(self) -> Iterable[str]: + """ + This should be a generator that yields `str` instances. + + It should yield the most recent items first, because they are the most + important. (The history can already be used, even when it's only + partially loaded.) + """ + while False: + yield + + @abstractmethod + def store_string(self, string: str) -> None: + """ + Store the string in persistent storage. + """ + + +class ThreadedHistory(History): + """ + Wrapper around `History` implementations that run the `load()` generator in + a thread. + + Use this to increase the start-up time of prompt_toolkit applications. + History entries are available as soon as they are loaded. We don't have to + wait for everything to be loaded. + """ + + def __init__(self, history: History) -> None: + super().__init__() + + self.history = history + + self._load_thread: threading.Thread | None = None + + # Lock for accessing/manipulating `_loaded_strings` and `_loaded` + # together in a consistent state. + self._lock = threading.Lock() + + # Events created by each `load()` call. Used to wait for new history + # entries from the loader thread. + self._string_load_events: list[threading.Event] = [] + + async def load(self) -> AsyncGenerator[str, None]: + """ + Like `History.load(), but call `self.load_history_strings()` in a + background thread. + """ + # Start the load thread, if this is called for the first time. + if not self._load_thread: + self._load_thread = threading.Thread( + target=self._in_load_thread, + daemon=True, + ) + self._load_thread.start() + + # Consume the `_loaded_strings` list, using asyncio. + loop = get_running_loop() + + # Create threading Event so that we can wait for new items. + event = threading.Event() + event.set() + self._string_load_events.append(event) + + items_yielded = 0 + + try: + while True: + # Wait for new items to be available. + # (Use a timeout, because the executor thread is not a daemon + # thread. The "slow-history.py" example would otherwise hang if + # Control-C is pressed before the history is fully loaded, + # because there's still this non-daemon executor thread waiting + # for this event.) + got_timeout = await loop.run_in_executor( + None, lambda: event.wait(timeout=0.5) + ) + if not got_timeout: + continue + + # Read new items (in lock). + def in_executor() -> tuple[list[str], bool]: + with self._lock: + new_items = self._loaded_strings[items_yielded:] + done = self._loaded + event.clear() + return new_items, done + + new_items, done = await loop.run_in_executor(None, in_executor) + + items_yielded += len(new_items) + + for item in new_items: + yield item + + if done: + break + finally: + self._string_load_events.remove(event) + + def _in_load_thread(self) -> None: + try: + # Start with an empty list. In case `append_string()` was called + # before `load()` happened. Then `.store_string()` will have + # written these entries back to disk and we will reload it. + self._loaded_strings = [] + + for item in self.history.load_history_strings(): + with self._lock: + self._loaded_strings.append(item) + + for event in self._string_load_events: + event.set() + finally: + with self._lock: + self._loaded = True + for event in self._string_load_events: + event.set() + + def append_string(self, string: str) -> None: + with self._lock: + self._loaded_strings.insert(0, string) + self.store_string(string) + + # All of the following are proxied to `self.history`. + + def load_history_strings(self) -> Iterable[str]: + return self.history.load_history_strings() + + def store_string(self, string: str) -> None: + self.history.store_string(string) + + def __repr__(self) -> str: + return f"ThreadedHistory({self.history!r})" + + +class InMemoryHistory(History): + """ + :class:`.History` class that keeps a list of all strings in memory. + + In order to prepopulate the history, it's possible to call either + `append_string` for all items or pass a list of strings to `__init__` here. + """ + + def __init__(self, history_strings: Sequence[str] | None = None) -> None: + super().__init__() + # Emulating disk storage. + if history_strings is None: + self._storage = [] + else: + self._storage = list(history_strings) + + def load_history_strings(self) -> Iterable[str]: + yield from self._storage[::-1] + + def store_string(self, string: str) -> None: + self._storage.append(string) + + +class DummyHistory(History): + """ + :class:`.History` object that doesn't remember anything. + """ + + def load_history_strings(self) -> Iterable[str]: + return [] + + def store_string(self, string: str) -> None: + pass + + def append_string(self, string: str) -> None: + # Don't remember this. + pass + + +class FileHistory(History): + """ + :class:`.History` class that stores all strings in a file. + """ + + def __init__(self, filename: str) -> None: + self.filename = filename + super().__init__() + + def load_history_strings(self) -> Iterable[str]: + strings: list[str] = [] + lines: list[str] = [] + + def add() -> None: + if lines: + # Join and drop trailing newline. + string = "".join(lines)[:-1] + + strings.append(string) + + if os.path.exists(self.filename): + with open(self.filename, "rb") as f: + for line_bytes in f: + line = line_bytes.decode("utf-8", errors="replace") + + if line.startswith("+"): + lines.append(line[1:]) + else: + add() + lines = [] + + add() + + # Reverse the order, because newest items have to go first. + return reversed(strings) + + def store_string(self, string: str) -> None: + # Save to file. + with open(self.filename, "ab") as f: + + def write(t: str) -> None: + f.write(t.encode("utf-8")) + + write("\n# %s\n" % datetime.datetime.now()) + for line in string.split("\n"): + write("+%s\n" % line) diff --git a/src/prompt_toolkit/input/__init__.py b/src/prompt_toolkit/input/__init__.py new file mode 100644 index 0000000..ed8631b --- /dev/null +++ b/src/prompt_toolkit/input/__init__.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from .base import DummyInput, Input, PipeInput +from .defaults import create_input, create_pipe_input + +__all__ = [ + # Base. + "Input", + "PipeInput", + "DummyInput", + # Defaults. + "create_input", + "create_pipe_input", +] diff --git a/src/prompt_toolkit/input/ansi_escape_sequences.py b/src/prompt_toolkit/input/ansi_escape_sequences.py new file mode 100644 index 0000000..5648c66 --- /dev/null +++ b/src/prompt_toolkit/input/ansi_escape_sequences.py @@ -0,0 +1,343 @@ +""" +Mappings from VT100 (ANSI) escape sequences to the corresponding prompt_toolkit +keys. + +We are not using the terminfo/termcap databases to detect the ANSI escape +sequences for the input. Instead, we recognize 99% of the most common +sequences. This works well, because in practice, every modern terminal is +mostly Xterm compatible. + +Some useful docs: +- Mintty: https://github.com/mintty/mintty/blob/master/wiki/Keycodes.md +""" +from __future__ import annotations + +from ..keys import Keys + +__all__ = [ + "ANSI_SEQUENCES", + "REVERSE_ANSI_SEQUENCES", +] + +# Mapping of vt100 escape codes to Keys. +ANSI_SEQUENCES: dict[str, Keys | tuple[Keys, ...]] = { + # Control keys. + "\x00": Keys.ControlAt, # Control-At (Also for Ctrl-Space) + "\x01": Keys.ControlA, # Control-A (home) + "\x02": Keys.ControlB, # Control-B (emacs cursor left) + "\x03": Keys.ControlC, # Control-C (interrupt) + "\x04": Keys.ControlD, # Control-D (exit) + "\x05": Keys.ControlE, # Control-E (end) + "\x06": Keys.ControlF, # Control-F (cursor forward) + "\x07": Keys.ControlG, # Control-G + "\x08": Keys.ControlH, # Control-H (8) (Identical to '\b') + "\x09": Keys.ControlI, # Control-I (9) (Identical to '\t') + "\x0a": Keys.ControlJ, # Control-J (10) (Identical to '\n') + "\x0b": Keys.ControlK, # Control-K (delete until end of line; vertical tab) + "\x0c": Keys.ControlL, # Control-L (clear; form feed) + "\x0d": Keys.ControlM, # Control-M (13) (Identical to '\r') + "\x0e": Keys.ControlN, # Control-N (14) (history forward) + "\x0f": Keys.ControlO, # Control-O (15) + "\x10": Keys.ControlP, # Control-P (16) (history back) + "\x11": Keys.ControlQ, # Control-Q + "\x12": Keys.ControlR, # Control-R (18) (reverse search) + "\x13": Keys.ControlS, # Control-S (19) (forward search) + "\x14": Keys.ControlT, # Control-T + "\x15": Keys.ControlU, # Control-U + "\x16": Keys.ControlV, # Control-V + "\x17": Keys.ControlW, # Control-W + "\x18": Keys.ControlX, # Control-X + "\x19": Keys.ControlY, # Control-Y (25) + "\x1a": Keys.ControlZ, # Control-Z + "\x1b": Keys.Escape, # Also Control-[ + "\x9b": Keys.ShiftEscape, + "\x1c": Keys.ControlBackslash, # Both Control-\ (also Ctrl-| ) + "\x1d": Keys.ControlSquareClose, # Control-] + "\x1e": Keys.ControlCircumflex, # Control-^ + "\x1f": Keys.ControlUnderscore, # Control-underscore (Also for Ctrl-hyphen.) + # ASCII Delete (0x7f) + # Vt220 (and Linux terminal) send this when pressing backspace. We map this + # to ControlH, because that will make it easier to create key bindings that + # work everywhere, with the trade-off that it's no longer possible to + # handle backspace and control-h individually for the few terminals that + # support it. (Most terminals send ControlH when backspace is pressed.) + # See: http://www.ibb.net/~anne/keyboard.html + "\x7f": Keys.ControlH, + # -- + # Various + "\x1b[1~": Keys.Home, # tmux + "\x1b[2~": Keys.Insert, + "\x1b[3~": Keys.Delete, + "\x1b[4~": Keys.End, # tmux + "\x1b[5~": Keys.PageUp, + "\x1b[6~": Keys.PageDown, + "\x1b[7~": Keys.Home, # xrvt + "\x1b[8~": Keys.End, # xrvt + "\x1b[Z": Keys.BackTab, # shift + tab + "\x1b\x09": Keys.BackTab, # Linux console + "\x1b[~": Keys.BackTab, # Windows console + # -- + # Function keys. + "\x1bOP": Keys.F1, + "\x1bOQ": Keys.F2, + "\x1bOR": Keys.F3, + "\x1bOS": Keys.F4, + "\x1b[[A": Keys.F1, # Linux console. + "\x1b[[B": Keys.F2, # Linux console. + "\x1b[[C": Keys.F3, # Linux console. + "\x1b[[D": Keys.F4, # Linux console. + "\x1b[[E": Keys.F5, # Linux console. + "\x1b[11~": Keys.F1, # rxvt-unicode + "\x1b[12~": Keys.F2, # rxvt-unicode + "\x1b[13~": Keys.F3, # rxvt-unicode + "\x1b[14~": Keys.F4, # rxvt-unicode + "\x1b[15~": Keys.F5, + "\x1b[17~": Keys.F6, + "\x1b[18~": Keys.F7, + "\x1b[19~": Keys.F8, + "\x1b[20~": Keys.F9, + "\x1b[21~": Keys.F10, + "\x1b[23~": Keys.F11, + "\x1b[24~": Keys.F12, + "\x1b[25~": Keys.F13, + "\x1b[26~": Keys.F14, + "\x1b[28~": Keys.F15, + "\x1b[29~": Keys.F16, + "\x1b[31~": Keys.F17, + "\x1b[32~": Keys.F18, + "\x1b[33~": Keys.F19, + "\x1b[34~": Keys.F20, + # Xterm + "\x1b[1;2P": Keys.F13, + "\x1b[1;2Q": Keys.F14, + # '\x1b[1;2R': Keys.F15, # Conflicts with CPR response. + "\x1b[1;2S": Keys.F16, + "\x1b[15;2~": Keys.F17, + "\x1b[17;2~": Keys.F18, + "\x1b[18;2~": Keys.F19, + "\x1b[19;2~": Keys.F20, + "\x1b[20;2~": Keys.F21, + "\x1b[21;2~": Keys.F22, + "\x1b[23;2~": Keys.F23, + "\x1b[24;2~": Keys.F24, + # -- + # CSI 27 disambiguated modified "other" keys (xterm) + # Ref: https://invisible-island.net/xterm/modified-keys.html + # These are currently unsupported, so just re-map some common ones to the + # unmodified versions + "\x1b[27;2;13~": Keys.ControlM, # Shift + Enter + "\x1b[27;5;13~": Keys.ControlM, # Ctrl + Enter + "\x1b[27;6;13~": Keys.ControlM, # Ctrl + Shift + Enter + # -- + # Control + function keys. + "\x1b[1;5P": Keys.ControlF1, + "\x1b[1;5Q": Keys.ControlF2, + # "\x1b[1;5R": Keys.ControlF3, # Conflicts with CPR response. + "\x1b[1;5S": Keys.ControlF4, + "\x1b[15;5~": Keys.ControlF5, + "\x1b[17;5~": Keys.ControlF6, + "\x1b[18;5~": Keys.ControlF7, + "\x1b[19;5~": Keys.ControlF8, + "\x1b[20;5~": Keys.ControlF9, + "\x1b[21;5~": Keys.ControlF10, + "\x1b[23;5~": Keys.ControlF11, + "\x1b[24;5~": Keys.ControlF12, + "\x1b[1;6P": Keys.ControlF13, + "\x1b[1;6Q": Keys.ControlF14, + # "\x1b[1;6R": Keys.ControlF15, # Conflicts with CPR response. + "\x1b[1;6S": Keys.ControlF16, + "\x1b[15;6~": Keys.ControlF17, + "\x1b[17;6~": Keys.ControlF18, + "\x1b[18;6~": Keys.ControlF19, + "\x1b[19;6~": Keys.ControlF20, + "\x1b[20;6~": Keys.ControlF21, + "\x1b[21;6~": Keys.ControlF22, + "\x1b[23;6~": Keys.ControlF23, + "\x1b[24;6~": Keys.ControlF24, + # -- + # Tmux (Win32 subsystem) sends the following scroll events. + "\x1b[62~": Keys.ScrollUp, + "\x1b[63~": Keys.ScrollDown, + "\x1b[200~": Keys.BracketedPaste, # Start of bracketed paste. + # -- + # Sequences generated by numpad 5. Not sure what it means. (It doesn't + # appear in 'infocmp'. Just ignore. + "\x1b[E": Keys.Ignore, # Xterm. + "\x1b[G": Keys.Ignore, # Linux console. + # -- + # Meta/control/escape + pageup/pagedown/insert/delete. + "\x1b[3;2~": Keys.ShiftDelete, # xterm, gnome-terminal. + "\x1b[5;2~": Keys.ShiftPageUp, + "\x1b[6;2~": Keys.ShiftPageDown, + "\x1b[2;3~": (Keys.Escape, Keys.Insert), + "\x1b[3;3~": (Keys.Escape, Keys.Delete), + "\x1b[5;3~": (Keys.Escape, Keys.PageUp), + "\x1b[6;3~": (Keys.Escape, Keys.PageDown), + "\x1b[2;4~": (Keys.Escape, Keys.ShiftInsert), + "\x1b[3;4~": (Keys.Escape, Keys.ShiftDelete), + "\x1b[5;4~": (Keys.Escape, Keys.ShiftPageUp), + "\x1b[6;4~": (Keys.Escape, Keys.ShiftPageDown), + "\x1b[3;5~": Keys.ControlDelete, # xterm, gnome-terminal. + "\x1b[5;5~": Keys.ControlPageUp, + "\x1b[6;5~": Keys.ControlPageDown, + "\x1b[3;6~": Keys.ControlShiftDelete, + "\x1b[5;6~": Keys.ControlShiftPageUp, + "\x1b[6;6~": Keys.ControlShiftPageDown, + "\x1b[2;7~": (Keys.Escape, Keys.ControlInsert), + "\x1b[5;7~": (Keys.Escape, Keys.ControlPageDown), + "\x1b[6;7~": (Keys.Escape, Keys.ControlPageDown), + "\x1b[2;8~": (Keys.Escape, Keys.ControlShiftInsert), + "\x1b[5;8~": (Keys.Escape, Keys.ControlShiftPageDown), + "\x1b[6;8~": (Keys.Escape, Keys.ControlShiftPageDown), + # -- + # Arrows. + # (Normal cursor mode). + "\x1b[A": Keys.Up, + "\x1b[B": Keys.Down, + "\x1b[C": Keys.Right, + "\x1b[D": Keys.Left, + "\x1b[H": Keys.Home, + "\x1b[F": Keys.End, + # Tmux sends following keystrokes when control+arrow is pressed, but for + # Emacs ansi-term sends the same sequences for normal arrow keys. Consider + # it a normal arrow press, because that's more important. + # (Application cursor mode). + "\x1bOA": Keys.Up, + "\x1bOB": Keys.Down, + "\x1bOC": Keys.Right, + "\x1bOD": Keys.Left, + "\x1bOF": Keys.End, + "\x1bOH": Keys.Home, + # Shift + arrows. + "\x1b[1;2A": Keys.ShiftUp, + "\x1b[1;2B": Keys.ShiftDown, + "\x1b[1;2C": Keys.ShiftRight, + "\x1b[1;2D": Keys.ShiftLeft, + "\x1b[1;2F": Keys.ShiftEnd, + "\x1b[1;2H": Keys.ShiftHome, + # Meta + arrow keys. Several terminals handle this differently. + # The following sequences are for xterm and gnome-terminal. + # (Iterm sends ESC followed by the normal arrow_up/down/left/right + # sequences, and the OSX Terminal sends ESCb and ESCf for "alt + # arrow_left" and "alt arrow_right." We don't handle these + # explicitly, in here, because would could not distinguish between + # pressing ESC (to go to Vi navigation mode), followed by just the + # 'b' or 'f' key. These combinations are handled in + # the input processor.) + "\x1b[1;3A": (Keys.Escape, Keys.Up), + "\x1b[1;3B": (Keys.Escape, Keys.Down), + "\x1b[1;3C": (Keys.Escape, Keys.Right), + "\x1b[1;3D": (Keys.Escape, Keys.Left), + "\x1b[1;3F": (Keys.Escape, Keys.End), + "\x1b[1;3H": (Keys.Escape, Keys.Home), + # Alt+shift+number. + "\x1b[1;4A": (Keys.Escape, Keys.ShiftDown), + "\x1b[1;4B": (Keys.Escape, Keys.ShiftUp), + "\x1b[1;4C": (Keys.Escape, Keys.ShiftRight), + "\x1b[1;4D": (Keys.Escape, Keys.ShiftLeft), + "\x1b[1;4F": (Keys.Escape, Keys.ShiftEnd), + "\x1b[1;4H": (Keys.Escape, Keys.ShiftHome), + # Control + arrows. + "\x1b[1;5A": Keys.ControlUp, # Cursor Mode + "\x1b[1;5B": Keys.ControlDown, # Cursor Mode + "\x1b[1;5C": Keys.ControlRight, # Cursor Mode + "\x1b[1;5D": Keys.ControlLeft, # Cursor Mode + "\x1b[1;5F": Keys.ControlEnd, + "\x1b[1;5H": Keys.ControlHome, + # Tmux sends following keystrokes when control+arrow is pressed, but for + # Emacs ansi-term sends the same sequences for normal arrow keys. Consider + # it a normal arrow press, because that's more important. + "\x1b[5A": Keys.ControlUp, + "\x1b[5B": Keys.ControlDown, + "\x1b[5C": Keys.ControlRight, + "\x1b[5D": Keys.ControlLeft, + "\x1bOc": Keys.ControlRight, # rxvt + "\x1bOd": Keys.ControlLeft, # rxvt + # Control + shift + arrows. + "\x1b[1;6A": Keys.ControlShiftDown, + "\x1b[1;6B": Keys.ControlShiftUp, + "\x1b[1;6C": Keys.ControlShiftRight, + "\x1b[1;6D": Keys.ControlShiftLeft, + "\x1b[1;6F": Keys.ControlShiftEnd, + "\x1b[1;6H": Keys.ControlShiftHome, + # Control + Meta + arrows. + "\x1b[1;7A": (Keys.Escape, Keys.ControlDown), + "\x1b[1;7B": (Keys.Escape, Keys.ControlUp), + "\x1b[1;7C": (Keys.Escape, Keys.ControlRight), + "\x1b[1;7D": (Keys.Escape, Keys.ControlLeft), + "\x1b[1;7F": (Keys.Escape, Keys.ControlEnd), + "\x1b[1;7H": (Keys.Escape, Keys.ControlHome), + # Meta + Shift + arrows. + "\x1b[1;8A": (Keys.Escape, Keys.ControlShiftDown), + "\x1b[1;8B": (Keys.Escape, Keys.ControlShiftUp), + "\x1b[1;8C": (Keys.Escape, Keys.ControlShiftRight), + "\x1b[1;8D": (Keys.Escape, Keys.ControlShiftLeft), + "\x1b[1;8F": (Keys.Escape, Keys.ControlShiftEnd), + "\x1b[1;8H": (Keys.Escape, Keys.ControlShiftHome), + # Meta + arrow on (some?) Macs when using iTerm defaults (see issue #483). + "\x1b[1;9A": (Keys.Escape, Keys.Up), + "\x1b[1;9B": (Keys.Escape, Keys.Down), + "\x1b[1;9C": (Keys.Escape, Keys.Right), + "\x1b[1;9D": (Keys.Escape, Keys.Left), + # -- + # Control/shift/meta + number in mintty. + # (c-2 will actually send c-@ and c-6 will send c-^.) + "\x1b[1;5p": Keys.Control0, + "\x1b[1;5q": Keys.Control1, + "\x1b[1;5r": Keys.Control2, + "\x1b[1;5s": Keys.Control3, + "\x1b[1;5t": Keys.Control4, + "\x1b[1;5u": Keys.Control5, + "\x1b[1;5v": Keys.Control6, + "\x1b[1;5w": Keys.Control7, + "\x1b[1;5x": Keys.Control8, + "\x1b[1;5y": Keys.Control9, + "\x1b[1;6p": Keys.ControlShift0, + "\x1b[1;6q": Keys.ControlShift1, + "\x1b[1;6r": Keys.ControlShift2, + "\x1b[1;6s": Keys.ControlShift3, + "\x1b[1;6t": Keys.ControlShift4, + "\x1b[1;6u": Keys.ControlShift5, + "\x1b[1;6v": Keys.ControlShift6, + "\x1b[1;6w": Keys.ControlShift7, + "\x1b[1;6x": Keys.ControlShift8, + "\x1b[1;6y": Keys.ControlShift9, + "\x1b[1;7p": (Keys.Escape, Keys.Control0), + "\x1b[1;7q": (Keys.Escape, Keys.Control1), + "\x1b[1;7r": (Keys.Escape, Keys.Control2), + "\x1b[1;7s": (Keys.Escape, Keys.Control3), + "\x1b[1;7t": (Keys.Escape, Keys.Control4), + "\x1b[1;7u": (Keys.Escape, Keys.Control5), + "\x1b[1;7v": (Keys.Escape, Keys.Control6), + "\x1b[1;7w": (Keys.Escape, Keys.Control7), + "\x1b[1;7x": (Keys.Escape, Keys.Control8), + "\x1b[1;7y": (Keys.Escape, Keys.Control9), + "\x1b[1;8p": (Keys.Escape, Keys.ControlShift0), + "\x1b[1;8q": (Keys.Escape, Keys.ControlShift1), + "\x1b[1;8r": (Keys.Escape, Keys.ControlShift2), + "\x1b[1;8s": (Keys.Escape, Keys.ControlShift3), + "\x1b[1;8t": (Keys.Escape, Keys.ControlShift4), + "\x1b[1;8u": (Keys.Escape, Keys.ControlShift5), + "\x1b[1;8v": (Keys.Escape, Keys.ControlShift6), + "\x1b[1;8w": (Keys.Escape, Keys.ControlShift7), + "\x1b[1;8x": (Keys.Escape, Keys.ControlShift8), + "\x1b[1;8y": (Keys.Escape, Keys.ControlShift9), +} + + +def _get_reverse_ansi_sequences() -> dict[Keys, str]: + """ + Create a dictionary that maps prompt_toolkit keys back to the VT100 escape + sequences. + """ + result: dict[Keys, str] = {} + + for sequence, key in ANSI_SEQUENCES.items(): + if not isinstance(key, tuple): + if key not in result: + result[key] = sequence + + return result + + +REVERSE_ANSI_SEQUENCES = _get_reverse_ansi_sequences() diff --git a/src/prompt_toolkit/input/base.py b/src/prompt_toolkit/input/base.py new file mode 100644 index 0000000..fd1429d --- /dev/null +++ b/src/prompt_toolkit/input/base.py @@ -0,0 +1,152 @@ +""" +Abstraction of CLI Input. +""" +from __future__ import annotations + +from abc import ABCMeta, abstractmethod, abstractproperty +from contextlib import contextmanager +from typing import Callable, ContextManager, Generator + +from prompt_toolkit.key_binding import KeyPress + +__all__ = [ + "Input", + "PipeInput", + "DummyInput", +] + + +class Input(metaclass=ABCMeta): + """ + Abstraction for any input. + + An instance of this class can be given to the constructor of a + :class:`~prompt_toolkit.application.Application` and will also be + passed to the :class:`~prompt_toolkit.eventloop.base.EventLoop`. + """ + + @abstractmethod + def fileno(self) -> int: + """ + Fileno for putting this in an event loop. + """ + + @abstractmethod + def typeahead_hash(self) -> str: + """ + Identifier for storing type ahead key presses. + """ + + @abstractmethod + def read_keys(self) -> list[KeyPress]: + """ + Return a list of Key objects which are read/parsed from the input. + """ + + def flush_keys(self) -> list[KeyPress]: + """ + Flush the underlying parser. and return the pending keys. + (Used for vt100 input.) + """ + return [] + + def flush(self) -> None: + "The event loop can call this when the input has to be flushed." + pass + + @abstractproperty + def closed(self) -> bool: + "Should be true when the input stream is closed." + return False + + @abstractmethod + def raw_mode(self) -> ContextManager[None]: + """ + Context manager that turns the input into raw mode. + """ + + @abstractmethod + def cooked_mode(self) -> ContextManager[None]: + """ + Context manager that turns the input into cooked mode. + """ + + @abstractmethod + def attach(self, input_ready_callback: Callable[[], None]) -> ContextManager[None]: + """ + Return a context manager that makes this input active in the current + event loop. + """ + + @abstractmethod + def detach(self) -> ContextManager[None]: + """ + Return a context manager that makes sure that this input is not active + in the current event loop. + """ + + def close(self) -> None: + "Close input." + pass + + +class PipeInput(Input): + """ + Abstraction for pipe input. + """ + + @abstractmethod + def send_bytes(self, data: bytes) -> None: + """Feed byte string into the pipe""" + + @abstractmethod + def send_text(self, data: str) -> None: + """Feed a text string into the pipe""" + + +class DummyInput(Input): + """ + Input for use in a `DummyApplication` + + If used in an actual application, it will make the application render + itself once and exit immediately, due to an `EOFError`. + """ + + def fileno(self) -> int: + raise NotImplementedError + + def typeahead_hash(self) -> str: + return "dummy-%s" % id(self) + + def read_keys(self) -> list[KeyPress]: + return [] + + @property + def closed(self) -> bool: + # This needs to be true, so that the dummy input will trigger an + # `EOFError` immediately in the application. + return True + + def raw_mode(self) -> ContextManager[None]: + return _dummy_context_manager() + + def cooked_mode(self) -> ContextManager[None]: + return _dummy_context_manager() + + def attach(self, input_ready_callback: Callable[[], None]) -> ContextManager[None]: + # Call the callback immediately once after attaching. + # This tells the callback to call `read_keys` and check the + # `input.closed` flag, after which it won't receive any keys, but knows + # that `EOFError` should be raised. This unblocks `read_from_input` in + # `application.py`. + input_ready_callback() + + return _dummy_context_manager() + + def detach(self) -> ContextManager[None]: + return _dummy_context_manager() + + +@contextmanager +def _dummy_context_manager() -> Generator[None, None, None]: + yield diff --git a/src/prompt_toolkit/input/defaults.py b/src/prompt_toolkit/input/defaults.py new file mode 100644 index 0000000..483eeb2 --- /dev/null +++ b/src/prompt_toolkit/input/defaults.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import io +import sys +from typing import ContextManager, TextIO + +from .base import DummyInput, Input, PipeInput + +__all__ = [ + "create_input", + "create_pipe_input", +] + + +def create_input(stdin: TextIO | None = None, always_prefer_tty: bool = False) -> Input: + """ + Create the appropriate `Input` object for the current os/environment. + + :param always_prefer_tty: When set, if `sys.stdin` is connected to a Unix + `pipe`, check whether `sys.stdout` or `sys.stderr` are connected to a + pseudo terminal. If so, open the tty for reading instead of reading for + `sys.stdin`. (We can open `stdout` or `stderr` for reading, this is how + a `$PAGER` works.) + """ + if sys.platform == "win32": + from .win32 import Win32Input + + # If `stdin` was assigned `None` (which happens with pythonw.exe), use + # a `DummyInput`. This triggers `EOFError` in the application code. + if stdin is None and sys.stdin is None: + return DummyInput() + + return Win32Input(stdin or sys.stdin) + else: + from .vt100 import Vt100Input + + # If no input TextIO is given, use stdin/stdout. + if stdin is None: + stdin = sys.stdin + + if always_prefer_tty: + for obj in [sys.stdin, sys.stdout, sys.stderr]: + if obj.isatty(): + stdin = obj + break + + # If we can't access the file descriptor for the selected stdin, return + # a `DummyInput` instead. This can happen for instance in unit tests, + # when `sys.stdin` is patched by something that's not an actual file. + # (Instantiating `Vt100Input` would fail in this case.) + try: + stdin.fileno() + except io.UnsupportedOperation: + return DummyInput() + + return Vt100Input(stdin) + + +def create_pipe_input() -> ContextManager[PipeInput]: + """ + Create an input pipe. + This is mostly useful for unit testing. + + Usage:: + + with create_pipe_input() as input: + input.send_text('inputdata') + + Breaking change: In prompt_toolkit 3.0.28 and earlier, this was returning + the `PipeInput` directly, rather than through a context manager. + """ + if sys.platform == "win32": + from .win32_pipe import Win32PipeInput + + return Win32PipeInput.create() + else: + from .posix_pipe import PosixPipeInput + + return PosixPipeInput.create() diff --git a/src/prompt_toolkit/input/posix_pipe.py b/src/prompt_toolkit/input/posix_pipe.py new file mode 100644 index 0000000..c131fb8 --- /dev/null +++ b/src/prompt_toolkit/input/posix_pipe.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import sys + +assert sys.platform != "win32" + +import os +from contextlib import contextmanager +from typing import ContextManager, Iterator, TextIO, cast + +from ..utils import DummyContext +from .base import PipeInput +from .vt100 import Vt100Input + +__all__ = [ + "PosixPipeInput", +] + + +class _Pipe: + "Wrapper around os.pipe, that ensures we don't double close any end." + + def __init__(self) -> None: + self.read_fd, self.write_fd = os.pipe() + self._read_closed = False + self._write_closed = False + + def close_read(self) -> None: + "Close read-end if not yet closed." + if self._read_closed: + return + + os.close(self.read_fd) + self._read_closed = True + + def close_write(self) -> None: + "Close write-end if not yet closed." + if self._write_closed: + return + + os.close(self.write_fd) + self._write_closed = True + + def close(self) -> None: + "Close both read and write ends." + self.close_read() + self.close_write() + + +class PosixPipeInput(Vt100Input, PipeInput): + """ + Input that is send through a pipe. + This is useful if we want to send the input programmatically into the + application. Mostly useful for unit testing. + + Usage:: + + with PosixPipeInput.create() as input: + input.send_text('inputdata') + """ + + _id = 0 + + def __init__(self, _pipe: _Pipe, _text: str = "") -> None: + # Private constructor. Users should use the public `.create()` method. + self.pipe = _pipe + + class Stdin: + encoding = "utf-8" + + def isatty(stdin) -> bool: + return True + + def fileno(stdin) -> int: + return self.pipe.read_fd + + super().__init__(cast(TextIO, Stdin())) + self.send_text(_text) + + # Identifier for every PipeInput for the hash. + self.__class__._id += 1 + self._id = self.__class__._id + + @classmethod + @contextmanager + def create(cls, text: str = "") -> Iterator[PosixPipeInput]: + pipe = _Pipe() + try: + yield PosixPipeInput(_pipe=pipe, _text=text) + finally: + pipe.close() + + def send_bytes(self, data: bytes) -> None: + os.write(self.pipe.write_fd, data) + + def send_text(self, data: str) -> None: + "Send text to the input." + os.write(self.pipe.write_fd, data.encode("utf-8")) + + def raw_mode(self) -> ContextManager[None]: + return DummyContext() + + def cooked_mode(self) -> ContextManager[None]: + return DummyContext() + + def close(self) -> None: + "Close pipe fds." + # Only close the write-end of the pipe. This will unblock the reader + # callback (in vt100.py > _attached_input), which eventually will raise + # `EOFError`. If we'd also close the read-end, then the event loop + # won't wake up the corresponding callback because of this. + self.pipe.close_write() + + def typeahead_hash(self) -> str: + """ + This needs to be unique for every `PipeInput`. + """ + return f"pipe-input-{self._id}" diff --git a/src/prompt_toolkit/input/posix_utils.py b/src/prompt_toolkit/input/posix_utils.py new file mode 100644 index 0000000..4a78dc4 --- /dev/null +++ b/src/prompt_toolkit/input/posix_utils.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import os +import select +from codecs import getincrementaldecoder + +__all__ = [ + "PosixStdinReader", +] + + +class PosixStdinReader: + """ + Wrapper around stdin which reads (nonblocking) the next available 1024 + bytes and decodes it. + + Note that you can't be sure that the input file is closed if the ``read`` + function returns an empty string. When ``errors=ignore`` is passed, + ``read`` can return an empty string if all malformed input was replaced by + an empty string. (We can't block here and wait for more input.) So, because + of that, check the ``closed`` attribute, to be sure that the file has been + closed. + + :param stdin_fd: File descriptor from which we read. + :param errors: Can be 'ignore', 'strict' or 'replace'. + On Python3, this can be 'surrogateescape', which is the default. + + 'surrogateescape' is preferred, because this allows us to transfer + unrecognized bytes to the key bindings. Some terminals, like lxterminal + and Guake, use the 'Mxx' notation to send mouse events, where each 'x' + can be any possible byte. + """ + + # By default, we want to 'ignore' errors here. The input stream can be full + # of junk. One occurrence of this that I had was when using iTerm2 on OS X, + # with "Option as Meta" checked (You should choose "Option as +Esc".) + + def __init__( + self, stdin_fd: int, errors: str = "surrogateescape", encoding: str = "utf-8" + ) -> None: + self.stdin_fd = stdin_fd + self.errors = errors + + # Create incremental decoder for decoding stdin. + # We can not just do `os.read(stdin.fileno(), 1024).decode('utf-8')`, because + # it could be that we are in the middle of a utf-8 byte sequence. + self._stdin_decoder_cls = getincrementaldecoder(encoding) + self._stdin_decoder = self._stdin_decoder_cls(errors=errors) + + #: True when there is nothing anymore to read. + self.closed = False + + def read(self, count: int = 1024) -> str: + # By default we choose a rather small chunk size, because reading + # big amounts of input at once, causes the event loop to process + # all these key bindings also at once without going back to the + # loop. This will make the application feel unresponsive. + """ + Read the input and return it as a string. + + Return the text. Note that this can return an empty string, even when + the input stream was not yet closed. This means that something went + wrong during the decoding. + """ + if self.closed: + return "" + + # Check whether there is some input to read. `os.read` 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 happens in certain situations.) + try: + if not select.select([self.stdin_fd], [], [], 0)[0]: + return "" + except OSError: + # Happens for instance when the file descriptor was closed. + # (We had this in ptterm, where the FD became ready, a callback was + # scheduled, but in the meantime another callback closed it already.) + self.closed = True + + # Note: the following works better than wrapping `self.stdin` like + # `codecs.getreader('utf-8')(stdin)` and doing `read(1)`. + # Somehow that causes some latency when the escape + # character is pressed. (Especially on combination with the `select`.) + try: + data = os.read(self.stdin_fd, count) + + # Nothing more to read, stream is closed. + if data == b"": + self.closed = True + return "" + except OSError: + # In case of SIGWINCH + data = b"" + + return self._stdin_decoder.decode(data) diff --git a/src/prompt_toolkit/input/typeahead.py b/src/prompt_toolkit/input/typeahead.py new file mode 100644 index 0000000..a45e7cf --- /dev/null +++ b/src/prompt_toolkit/input/typeahead.py @@ -0,0 +1,77 @@ +r""" +Store input key strokes if we did read more than was required. + +The input classes `Vt100Input` and `Win32Input` read the input text in chunks +of a few kilobytes. This means that if we read input from stdin, it could be +that we read a couple of lines (with newlines in between) at once. + +This creates a problem: potentially, we read too much from stdin. Sometimes +people paste several lines at once because they paste input in a REPL and +expect each input() call to process one line. Or they rely on type ahead +because the application can't keep up with the processing. + +However, we need to read input in bigger chunks. We need this mostly to support +pasting of larger chunks of text. We don't want everything to become +unresponsive because we: + - read one character; + - parse one character; + - call the key binding, which does a string operation with one character; + - and render the user interface. +Doing text operations on single characters is very inefficient in Python, so we +prefer to work on bigger chunks of text. This is why we have to read the input +in bigger chunks. + +Further, line buffering is also not an option, because it doesn't work well in +the architecture. We use lower level Posix APIs, that work better with the +event loop and so on. In fact, there is also nothing that defines that only \n +can accept the input, you could create a key binding for any key to accept the +input. + +To support type ahead, this module will store all the key strokes that were +read too early, so that they can be feed into to the next `prompt()` call or to +the next prompt_toolkit `Application`. +""" +from __future__ import annotations + +from collections import defaultdict + +from ..key_binding import KeyPress +from .base import Input + +__all__ = [ + "store_typeahead", + "get_typeahead", + "clear_typeahead", +] + +_buffer: dict[str, list[KeyPress]] = defaultdict(list) + + +def store_typeahead(input_obj: Input, key_presses: list[KeyPress]) -> None: + """ + Insert typeahead key presses for the given input. + """ + global _buffer + key = input_obj.typeahead_hash() + _buffer[key].extend(key_presses) + + +def get_typeahead(input_obj: Input) -> list[KeyPress]: + """ + Retrieve typeahead and reset the buffer for this input. + """ + global _buffer + + key = input_obj.typeahead_hash() + result = _buffer[key] + _buffer[key] = [] + return result + + +def clear_typeahead(input_obj: Input) -> None: + """ + Clear typeahead buffer. + """ + global _buffer + key = input_obj.typeahead_hash() + _buffer[key] = [] diff --git a/src/prompt_toolkit/input/vt100.py b/src/prompt_toolkit/input/vt100.py new file mode 100644 index 0000000..c1660de --- /dev/null +++ b/src/prompt_toolkit/input/vt100.py @@ -0,0 +1,309 @@ +from __future__ import annotations + +import sys + +assert sys.platform != "win32" + +import contextlib +import io +import termios +import tty +from asyncio import AbstractEventLoop, get_running_loop +from typing import Callable, ContextManager, Generator, TextIO + +from ..key_binding import KeyPress +from .base import Input +from .posix_utils import PosixStdinReader +from .vt100_parser import Vt100Parser + +__all__ = [ + "Vt100Input", + "raw_mode", + "cooked_mode", +] + + +class Vt100Input(Input): + """ + Vt100 input for Posix systems. + (This uses a posix file descriptor that can be registered in the event loop.) + """ + + # For the error messages. Only display "Input is not a terminal" once per + # file descriptor. + _fds_not_a_terminal: set[int] = set() + + def __init__(self, stdin: TextIO) -> None: + # Test whether the given input object has a file descriptor. + # (Idle reports stdin to be a TTY, but fileno() is not implemented.) + try: + # This should not raise, but can return 0. + stdin.fileno() + except io.UnsupportedOperation as e: + if "idlelib.run" in sys.modules: + raise io.UnsupportedOperation( + "Stdin is not a terminal. Running from Idle is not supported." + ) from e + else: + raise io.UnsupportedOperation("Stdin is not a terminal.") from e + + # Even when we have a file descriptor, it doesn't mean it's a TTY. + # Normally, this requires a real TTY device, but people instantiate + # this class often during unit tests as well. They use for instance + # pexpect to pipe data into an application. For convenience, we print + # an error message and go on. + isatty = stdin.isatty() + fd = stdin.fileno() + + if not isatty and fd not in Vt100Input._fds_not_a_terminal: + msg = "Warning: Input is not a terminal (fd=%r).\n" + sys.stderr.write(msg % fd) + sys.stderr.flush() + Vt100Input._fds_not_a_terminal.add(fd) + + # + self.stdin = stdin + + # Create a backup of the fileno(). We want this to work even if the + # underlying file is closed, so that `typeahead_hash()` keeps working. + self._fileno = stdin.fileno() + + self._buffer: list[KeyPress] = [] # Buffer to collect the Key objects. + self.stdin_reader = PosixStdinReader(self._fileno, encoding=stdin.encoding) + self.vt100_parser = Vt100Parser( + lambda key_press: self._buffer.append(key_press) + ) + + def attach(self, input_ready_callback: Callable[[], None]) -> ContextManager[None]: + """ + Return a context manager that makes this input active in the current + event loop. + """ + return _attached_input(self, input_ready_callback) + + def detach(self) -> ContextManager[None]: + """ + Return a context manager that makes sure that this input is not active + in the current event loop. + """ + return _detached_input(self) + + def read_keys(self) -> list[KeyPress]: + "Read list of KeyPress." + # Read text from stdin. + data = self.stdin_reader.read() + + # Pass it through our vt100 parser. + self.vt100_parser.feed(data) + + # Return result. + result = self._buffer + self._buffer = [] + return result + + def flush_keys(self) -> list[KeyPress]: + """ + Flush pending keys and return them. + (Used for flushing the 'escape' key.) + """ + # Flush all pending keys. (This is most important to flush the vt100 + # 'Escape' key early when nothing else follows.) + self.vt100_parser.flush() + + # Return result. + result = self._buffer + self._buffer = [] + return result + + @property + def closed(self) -> bool: + return self.stdin_reader.closed + + def raw_mode(self) -> ContextManager[None]: + return raw_mode(self.stdin.fileno()) + + def cooked_mode(self) -> ContextManager[None]: + return cooked_mode(self.stdin.fileno()) + + def fileno(self) -> int: + return self.stdin.fileno() + + def typeahead_hash(self) -> str: + return f"fd-{self._fileno}" + + +_current_callbacks: dict[ + tuple[AbstractEventLoop, int], Callable[[], None] | None +] = {} # (loop, fd) -> current callback + + +@contextlib.contextmanager +def _attached_input( + input: Vt100Input, callback: Callable[[], None] +) -> Generator[None, None, None]: + """ + Context manager that makes this input active in the current event loop. + + :param input: :class:`~prompt_toolkit.input.Input` object. + :param callback: Called when the input is ready to read. + """ + loop = get_running_loop() + fd = input.fileno() + previous = _current_callbacks.get((loop, fd)) + + def callback_wrapper() -> None: + """Wrapper around the callback that already removes the reader when + the input is closed. Otherwise, we keep continuously calling this + callback, until we leave the context manager (which can happen a bit + later). This fixes issues when piping /dev/null into a prompt_toolkit + application.""" + if input.closed: + loop.remove_reader(fd) + callback() + + try: + loop.add_reader(fd, callback_wrapper) + except PermissionError: + # For `EPollSelector`, adding /dev/null to the event loop will raise + # `PermissionError` (that doesn't happen for `SelectSelector` + # apparently). Whenever we get a `PermissionError`, we can raise + # `EOFError`, because there's not more to be read anyway. `EOFError` is + # an exception that people expect in + # `prompt_toolkit.application.Application.run()`. + # To reproduce, do: `ptpython 0< /dev/null 1< /dev/null` + raise EOFError + + _current_callbacks[loop, fd] = callback + + try: + yield + finally: + loop.remove_reader(fd) + + if previous: + loop.add_reader(fd, previous) + _current_callbacks[loop, fd] = previous + else: + del _current_callbacks[loop, fd] + + +@contextlib.contextmanager +def _detached_input(input: Vt100Input) -> Generator[None, None, None]: + loop = get_running_loop() + fd = input.fileno() + previous = _current_callbacks.get((loop, fd)) + + if previous: + loop.remove_reader(fd) + _current_callbacks[loop, fd] = None + + try: + yield + finally: + if previous: + loop.add_reader(fd, previous) + _current_callbacks[loop, fd] = previous + + +class raw_mode: + """ + :: + + with raw_mode(stdin): + ''' the pseudo-terminal stdin is now used in raw mode ''' + + We ignore errors when executing `tcgetattr` fails. + """ + + # There are several reasons for ignoring errors: + # 1. To avoid the "Inappropriate ioctl for device" crash if somebody would + # execute this code (In a Python REPL, for instance): + # + # import os; f = open(os.devnull); os.dup2(f.fileno(), 0) + # + # The result is that the eventloop will stop correctly, because it has + # to logic to quit when stdin is closed. However, we should not fail at + # this point. See: + # https://github.com/jonathanslenders/python-prompt-toolkit/pull/393 + # https://github.com/jonathanslenders/python-prompt-toolkit/issues/392 + + # 2. Related, when stdin is an SSH pipe, and no full terminal was allocated. + # See: https://github.com/jonathanslenders/python-prompt-toolkit/pull/165 + def __init__(self, fileno: int) -> None: + self.fileno = fileno + self.attrs_before: list[int | list[bytes | int]] | None + try: + self.attrs_before = termios.tcgetattr(fileno) + except termios.error: + # Ignore attribute errors. + self.attrs_before = None + + def __enter__(self) -> None: + # NOTE: On os X systems, using pty.setraw() fails. Therefor we are using this: + try: + newattr = termios.tcgetattr(self.fileno) + except termios.error: + pass + else: + newattr[tty.LFLAG] = self._patch_lflag(newattr[tty.LFLAG]) + newattr[tty.IFLAG] = self._patch_iflag(newattr[tty.IFLAG]) + + # VMIN defines the number of characters read at a time in + # non-canonical mode. It seems to default to 1 on Linux, but on + # Solaris and derived operating systems it defaults to 4. (This is + # because the VMIN slot is the same as the VEOF slot, which + # defaults to ASCII EOT = Ctrl-D = 4.) + newattr[tty.CC][termios.VMIN] = 1 + + termios.tcsetattr(self.fileno, termios.TCSANOW, newattr) + + @classmethod + def _patch_lflag(cls, attrs: int) -> int: + return attrs & ~(termios.ECHO | termios.ICANON | termios.IEXTEN | termios.ISIG) + + @classmethod + def _patch_iflag(cls, attrs: int) -> int: + return attrs & ~( + # Disable XON/XOFF flow control on output and input. + # (Don't capture Ctrl-S and Ctrl-Q.) + # Like executing: "stty -ixon." + termios.IXON + | termios.IXOFF + | + # Don't translate carriage return into newline on input. + termios.ICRNL + | termios.INLCR + | termios.IGNCR + ) + + def __exit__(self, *a: object) -> None: + if self.attrs_before is not None: + try: + termios.tcsetattr(self.fileno, termios.TCSANOW, self.attrs_before) + except termios.error: + pass + + # # Put the terminal in application mode. + # self._stdout.write('\x1b[?1h') + + +class cooked_mode(raw_mode): + """ + The opposite of ``raw_mode``, used when we need cooked mode inside a + `raw_mode` block. Used in `Application.run_in_terminal`.:: + + with cooked_mode(stdin): + ''' the pseudo-terminal stdin is now used in cooked mode. ''' + """ + + @classmethod + def _patch_lflag(cls, attrs: int) -> int: + return attrs | (termios.ECHO | termios.ICANON | termios.IEXTEN | termios.ISIG) + + @classmethod + def _patch_iflag(cls, attrs: int) -> int: + # Turn the ICRNL flag back on. (Without this, calling `input()` in + # run_in_terminal doesn't work and displays ^M instead. Ptpython + # evaluates commands using `run_in_terminal`, so it's important that + # they translate ^M back into ^J.) + return attrs | termios.ICRNL diff --git a/src/prompt_toolkit/input/vt100_parser.py b/src/prompt_toolkit/input/vt100_parser.py new file mode 100644 index 0000000..99e2d99 --- /dev/null +++ b/src/prompt_toolkit/input/vt100_parser.py @@ -0,0 +1,249 @@ +""" +Parser for VT100 input stream. +""" +from __future__ import annotations + +import re +from typing import Callable, Dict, Generator + +from ..key_binding.key_processor import KeyPress +from ..keys import Keys +from .ansi_escape_sequences import ANSI_SEQUENCES + +__all__ = [ + "Vt100Parser", +] + + +# Regex matching any CPR response +# (Note that we use '\Z' instead of '$', because '$' could include a trailing +# newline.) +_cpr_response_re = re.compile("^" + re.escape("\x1b[") + r"\d+;\d+R\Z") + +# Mouse events: +# Typical: "Esc[MaB*" Urxvt: "Esc[96;14;13M" and for Xterm SGR: "Esc[<64;85;12M" +_mouse_event_re = re.compile("^" + re.escape("\x1b[") + r"(<?[\d;]+[mM]|M...)\Z") + +# Regex matching any valid prefix of a CPR response. +# (Note that it doesn't contain the last character, the 'R'. The prefix has to +# be shorter.) +_cpr_response_prefix_re = re.compile("^" + re.escape("\x1b[") + r"[\d;]*\Z") + +_mouse_event_prefix_re = re.compile("^" + re.escape("\x1b[") + r"(<?[\d;]*|M.{0,2})\Z") + + +class _Flush: + """Helper object to indicate flush operation to the parser.""" + + pass + + +class _IsPrefixOfLongerMatchCache(Dict[str, bool]): + """ + Dictionary that maps input sequences to a boolean indicating whether there is + any key that start with this characters. + """ + + def __missing__(self, prefix: str) -> bool: + # (hard coded) If this could be a prefix of a CPR response, return + # True. + if _cpr_response_prefix_re.match(prefix) or _mouse_event_prefix_re.match( + prefix + ): + result = True + else: + # If this could be a prefix of anything else, also return True. + result = any( + v + for k, v in ANSI_SEQUENCES.items() + if k.startswith(prefix) and k != prefix + ) + + self[prefix] = result + return result + + +_IS_PREFIX_OF_LONGER_MATCH_CACHE = _IsPrefixOfLongerMatchCache() + + +class Vt100Parser: + """ + Parser for VT100 input stream. + Data can be fed through the `feed` method and the given callback will be + called with KeyPress objects. + + :: + + def callback(key): + pass + i = Vt100Parser(callback) + i.feed('data\x01...') + + :attr feed_key_callback: Function that will be called when a key is parsed. + """ + + # Lookup table of ANSI escape sequences for a VT100 terminal + # Hint: in order to know what sequences your terminal writes to stdin, run + # "od -c" and start typing. + def __init__(self, feed_key_callback: Callable[[KeyPress], None]) -> None: + self.feed_key_callback = feed_key_callback + self.reset() + + def reset(self, request: bool = False) -> None: + self._in_bracketed_paste = False + self._start_parser() + + def _start_parser(self) -> None: + """ + Start the parser coroutine. + """ + self._input_parser = self._input_parser_generator() + self._input_parser.send(None) # type: ignore + + def _get_match(self, prefix: str) -> None | Keys | tuple[Keys, ...]: + """ + Return the key (or keys) that maps to this prefix. + """ + # (hard coded) If we match a CPR response, return Keys.CPRResponse. + # (This one doesn't fit in the ANSI_SEQUENCES, because it contains + # integer variables.) + if _cpr_response_re.match(prefix): + return Keys.CPRResponse + + elif _mouse_event_re.match(prefix): + return Keys.Vt100MouseEvent + + # Otherwise, use the mappings. + try: + return ANSI_SEQUENCES[prefix] + except KeyError: + return None + + def _input_parser_generator(self) -> Generator[None, str | _Flush, None]: + """ + Coroutine (state machine) for the input parser. + """ + prefix = "" + retry = False + flush = False + + while True: + flush = False + + if retry: + retry = False + else: + # Get next character. + c = yield + + if isinstance(c, _Flush): + flush = True + else: + prefix += c + + # If we have some data, check for matches. + if prefix: + is_prefix_of_longer_match = _IS_PREFIX_OF_LONGER_MATCH_CACHE[prefix] + match = self._get_match(prefix) + + # Exact matches found, call handlers.. + if (flush or not is_prefix_of_longer_match) and match: + self._call_handler(match, prefix) + prefix = "" + + # No exact match found. + elif (flush or not is_prefix_of_longer_match) and not match: + found = False + retry = True + + # Loop over the input, try the longest match first and + # shift. + for i in range(len(prefix), 0, -1): + match = self._get_match(prefix[:i]) + if match: + self._call_handler(match, prefix[:i]) + prefix = prefix[i:] + found = True + + if not found: + self._call_handler(prefix[0], prefix[0]) + prefix = prefix[1:] + + def _call_handler( + self, key: str | Keys | tuple[Keys, ...], insert_text: str + ) -> None: + """ + Callback to handler. + """ + if isinstance(key, tuple): + # Received ANSI sequence that corresponds with multiple keys + # (probably alt+something). Handle keys individually, but only pass + # data payload to first KeyPress (so that we won't insert it + # multiple times). + for i, k in enumerate(key): + self._call_handler(k, insert_text if i == 0 else "") + else: + if key == Keys.BracketedPaste: + self._in_bracketed_paste = True + self._paste_buffer = "" + else: + self.feed_key_callback(KeyPress(key, insert_text)) + + def feed(self, data: str) -> None: + """ + Feed the input stream. + + :param data: Input string (unicode). + """ + # Handle bracketed paste. (We bypass the parser that matches all other + # key presses and keep reading input until we see the end mark.) + # This is much faster then parsing character by character. + if self._in_bracketed_paste: + self._paste_buffer += data + end_mark = "\x1b[201~" + + if end_mark in self._paste_buffer: + end_index = self._paste_buffer.index(end_mark) + + # Feed content to key bindings. + paste_content = self._paste_buffer[:end_index] + self.feed_key_callback(KeyPress(Keys.BracketedPaste, paste_content)) + + # Quit bracketed paste mode and handle remaining input. + self._in_bracketed_paste = False + remaining = self._paste_buffer[end_index + len(end_mark) :] + self._paste_buffer = "" + + self.feed(remaining) + + # Handle normal input character by character. + else: + for i, c in enumerate(data): + if self._in_bracketed_paste: + # Quit loop and process from this position when the parser + # entered bracketed paste. + self.feed(data[i:]) + break + else: + self._input_parser.send(c) + + def flush(self) -> None: + """ + Flush the buffer of the input stream. + + This will allow us to handle the escape key (or maybe meta) sooner. + The input received by the escape key is actually the same as the first + characters of e.g. Arrow-Up, so without knowing what follows the escape + sequence, we don't know whether escape has been pressed, or whether + it's something else. This flush function should be called after a + timeout, and processes everything that's still in the buffer as-is, so + without assuming any characters will follow. + """ + self._input_parser.send(_Flush()) + + def feed_and_flush(self, data: str) -> None: + """ + Wrapper around ``feed`` and ``flush``. + """ + self.feed(data) + self.flush() diff --git a/src/prompt_toolkit/input/win32.py b/src/prompt_toolkit/input/win32.py new file mode 100644 index 0000000..35e8948 --- /dev/null +++ b/src/prompt_toolkit/input/win32.py @@ -0,0 +1,749 @@ +from __future__ import annotations + +import os +import sys +from abc import abstractmethod +from asyncio import get_running_loop +from contextlib import contextmanager + +from ..utils import SPHINX_AUTODOC_RUNNING + +assert sys.platform == "win32" + +# Do not import win32-specific stuff when generating documentation. +# Otherwise RTD would be unable to generate docs for this module. +if not SPHINX_AUTODOC_RUNNING: + import msvcrt + from ctypes import windll + +from ctypes import Array, pointer +from ctypes.wintypes import DWORD, HANDLE +from typing import Callable, ContextManager, Iterable, Iterator, TextIO + +from prompt_toolkit.eventloop import run_in_executor_with_context +from prompt_toolkit.eventloop.win32 import create_win32_event, wait_for_handles +from prompt_toolkit.key_binding.key_processor import KeyPress +from prompt_toolkit.keys import Keys +from prompt_toolkit.mouse_events import MouseButton, MouseEventType +from prompt_toolkit.win32_types import ( + INPUT_RECORD, + KEY_EVENT_RECORD, + MOUSE_EVENT_RECORD, + STD_INPUT_HANDLE, + EventTypes, +) + +from .ansi_escape_sequences import REVERSE_ANSI_SEQUENCES +from .base import Input + +__all__ = [ + "Win32Input", + "ConsoleInputReader", + "raw_mode", + "cooked_mode", + "attach_win32_input", + "detach_win32_input", +] + +# Win32 Constants for MOUSE_EVENT_RECORD. +# See: https://docs.microsoft.com/en-us/windows/console/mouse-event-record-str +FROM_LEFT_1ST_BUTTON_PRESSED = 0x1 +RIGHTMOST_BUTTON_PRESSED = 0x2 +MOUSE_MOVED = 0x0001 +MOUSE_WHEELED = 0x0004 + + +class _Win32InputBase(Input): + """ + Base class for `Win32Input` and `Win32PipeInput`. + """ + + def __init__(self) -> None: + self.win32_handles = _Win32Handles() + + @property + @abstractmethod + def handle(self) -> HANDLE: + pass + + +class Win32Input(_Win32InputBase): + """ + `Input` class that reads from the Windows console. + """ + + def __init__(self, stdin: TextIO | None = None) -> None: + super().__init__() + self.console_input_reader = ConsoleInputReader() + + def attach(self, input_ready_callback: Callable[[], None]) -> ContextManager[None]: + """ + Return a context manager that makes this input active in the current + event loop. + """ + return attach_win32_input(self, input_ready_callback) + + def detach(self) -> ContextManager[None]: + """ + Return a context manager that makes sure that this input is not active + in the current event loop. + """ + return detach_win32_input(self) + + def read_keys(self) -> list[KeyPress]: + return list(self.console_input_reader.read()) + + def flush(self) -> None: + pass + + @property + def closed(self) -> bool: + return False + + def raw_mode(self) -> ContextManager[None]: + return raw_mode() + + def cooked_mode(self) -> ContextManager[None]: + return cooked_mode() + + def fileno(self) -> int: + # The windows console doesn't depend on the file handle, so + # this is not used for the event loop (which uses the + # handle instead). But it's used in `Application.run_system_command` + # which opens a subprocess with a given stdin/stdout. + return sys.stdin.fileno() + + def typeahead_hash(self) -> str: + return "win32-input" + + def close(self) -> None: + self.console_input_reader.close() + + @property + def handle(self) -> HANDLE: + return self.console_input_reader.handle + + +class ConsoleInputReader: + """ + :param recognize_paste: When True, try to discover paste actions and turn + the event into a BracketedPaste. + """ + + # Keys with character data. + mappings = { + b"\x1b": Keys.Escape, + b"\x00": Keys.ControlSpace, # Control-Space (Also for Ctrl-@) + b"\x01": Keys.ControlA, # Control-A (home) + b"\x02": Keys.ControlB, # Control-B (emacs cursor left) + b"\x03": Keys.ControlC, # Control-C (interrupt) + b"\x04": Keys.ControlD, # Control-D (exit) + b"\x05": Keys.ControlE, # Control-E (end) + b"\x06": Keys.ControlF, # Control-F (cursor forward) + b"\x07": Keys.ControlG, # Control-G + b"\x08": Keys.ControlH, # Control-H (8) (Identical to '\b') + b"\x09": Keys.ControlI, # Control-I (9) (Identical to '\t') + b"\x0a": Keys.ControlJ, # Control-J (10) (Identical to '\n') + b"\x0b": Keys.ControlK, # Control-K (delete until end of line; vertical tab) + b"\x0c": Keys.ControlL, # Control-L (clear; form feed) + b"\x0d": Keys.ControlM, # Control-M (enter) + b"\x0e": Keys.ControlN, # Control-N (14) (history forward) + b"\x0f": Keys.ControlO, # Control-O (15) + b"\x10": Keys.ControlP, # Control-P (16) (history back) + b"\x11": Keys.ControlQ, # Control-Q + b"\x12": Keys.ControlR, # Control-R (18) (reverse search) + b"\x13": Keys.ControlS, # Control-S (19) (forward search) + b"\x14": Keys.ControlT, # Control-T + b"\x15": Keys.ControlU, # Control-U + b"\x16": Keys.ControlV, # Control-V + b"\x17": Keys.ControlW, # Control-W + b"\x18": Keys.ControlX, # Control-X + b"\x19": Keys.ControlY, # Control-Y (25) + b"\x1a": Keys.ControlZ, # Control-Z + b"\x1c": Keys.ControlBackslash, # Both Control-\ and Ctrl-| + b"\x1d": Keys.ControlSquareClose, # Control-] + b"\x1e": Keys.ControlCircumflex, # Control-^ + b"\x1f": Keys.ControlUnderscore, # Control-underscore (Also for Ctrl-hyphen.) + b"\x7f": Keys.Backspace, # (127) Backspace (ASCII Delete.) + } + + # Keys that don't carry character data. + keycodes = { + # Home/End + 33: Keys.PageUp, + 34: Keys.PageDown, + 35: Keys.End, + 36: Keys.Home, + # Arrows + 37: Keys.Left, + 38: Keys.Up, + 39: Keys.Right, + 40: Keys.Down, + 45: Keys.Insert, + 46: Keys.Delete, + # F-keys. + 112: Keys.F1, + 113: Keys.F2, + 114: Keys.F3, + 115: Keys.F4, + 116: Keys.F5, + 117: Keys.F6, + 118: Keys.F7, + 119: Keys.F8, + 120: Keys.F9, + 121: Keys.F10, + 122: Keys.F11, + 123: Keys.F12, + } + + LEFT_ALT_PRESSED = 0x0002 + RIGHT_ALT_PRESSED = 0x0001 + SHIFT_PRESSED = 0x0010 + LEFT_CTRL_PRESSED = 0x0008 + RIGHT_CTRL_PRESSED = 0x0004 + + def __init__(self, recognize_paste: bool = True) -> None: + self._fdcon = None + self.recognize_paste = recognize_paste + + # 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. + all_keys = list(self._get_keys(read, input_records)) + + # Fill in 'data' for key presses. + all_keys = [self._insert_key_data(key) for key in all_keys] + + # Correct non-bmp characters that are passed as separate surrogate codes + all_keys = list(self._merge_paired_surrogates(all_keys)) + + if self.recognize_paste and self._is_paste(all_keys): + gen = iter(all_keys) + k: KeyPress | None + + for k in gen: + # Pasting: if the current key consists of text or \n, turn it + # into a BracketedPaste. + data = [] + while k and ( + not isinstance(k.key, Keys) + or k.key in {Keys.ControlJ, Keys.ControlM} + ): + data.append(k.data) + try: + k = next(gen) + except StopIteration: + k = None + + if data: + yield KeyPress(Keys.BracketedPaste, "".join(data)) + if k is not None: + yield k + else: + yield from all_keys + + def _insert_key_data(self, key_press: KeyPress) -> KeyPress: + """ + Insert KeyPress data, for vt100 compatibility. + """ + if key_press.data: + return key_press + + if isinstance(key_press.key, Keys): + data = REVERSE_ANSI_SEQUENCES.get(key_press.key, "") + else: + data = "" + + return KeyPress(key_press.key, data) + + def _get_keys( + self, read: DWORD, input_records: Array[INPUT_RECORD] + ) -> Iterator[KeyPress]: + """ + 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: + yield from self._event_to_key_presses(ev) + + elif isinstance(ev, MOUSE_EVENT_RECORD): + yield from self._handle_mouse(ev) + + @staticmethod + def _merge_paired_surrogates(key_presses: list[KeyPress]) -> Iterator[KeyPress]: + """ + Combines consecutive KeyPresses with high and low surrogates into + single characters + """ + buffered_high_surrogate = None + for key in key_presses: + is_text = not isinstance(key.key, Keys) + is_high_surrogate = is_text and "\uD800" <= key.key <= "\uDBFF" + is_low_surrogate = is_text and "\uDC00" <= key.key <= "\uDFFF" + + if buffered_high_surrogate: + if is_low_surrogate: + # convert high surrogate + low surrogate to single character + fullchar = ( + (buffered_high_surrogate.key + key.key) + .encode("utf-16-le", "surrogatepass") + .decode("utf-16-le") + ) + key = KeyPress(fullchar, fullchar) + else: + yield buffered_high_surrogate + buffered_high_surrogate = None + + if is_high_surrogate: + buffered_high_surrogate = key + else: + yield key + + if buffered_high_surrogate: + yield buffered_high_surrogate + + @staticmethod + def _is_paste(keys: list[KeyPress]) -> bool: + """ + Return `True` when we should consider this list of keys as a paste + event. Pasted text on windows will be turned into a + `Keys.BracketedPaste` event. (It's not 100% correct, but it is probably + the best possible way to detect pasting of text and handle that + correctly.) + """ + # Consider paste when it contains at least one newline and at least one + # other character. + text_count = 0 + newline_count = 0 + + for k in keys: + if not isinstance(k.key, Keys): + text_count += 1 + if k.key == Keys.ControlM: + newline_count += 1 + + return newline_count >= 1 and text_count >= 1 + + def _event_to_key_presses(self, ev: KEY_EVENT_RECORD) -> list[KeyPress]: + """ + For this `KEY_EVENT_RECORD`, return a list of `KeyPress` instances. + """ + assert isinstance(ev, KEY_EVENT_RECORD) and ev.KeyDown + + result: KeyPress | None = None + + control_key_state = ev.ControlKeyState + u_char = ev.uChar.UnicodeChar + # Use surrogatepass because u_char may be an unmatched surrogate + ascii_char = u_char.encode("utf-8", "surrogatepass") + + # NOTE: We don't use `ev.uChar.AsciiChar`. That appears to be the + # unicode code point truncated to 1 byte. See also: + # https://github.com/ipython/ipython/issues/10004 + # https://github.com/jonathanslenders/python-prompt-toolkit/issues/389 + + if u_char == "\x00": + if ev.VirtualKeyCode in self.keycodes: + result = KeyPress(self.keycodes[ev.VirtualKeyCode], "") + else: + if ascii_char in self.mappings: + if self.mappings[ascii_char] == Keys.ControlJ: + u_char = ( + "\n" # Windows sends \n, turn into \r for unix compatibility. + ) + result = KeyPress(self.mappings[ascii_char], u_char) + else: + result = KeyPress(u_char, u_char) + + # First we handle Shift-Control-Arrow/Home/End (need to do this first) + if ( + ( + control_key_state & self.LEFT_CTRL_PRESSED + or control_key_state & self.RIGHT_CTRL_PRESSED + ) + and control_key_state & self.SHIFT_PRESSED + and result + ): + mapping: dict[str, str] = { + Keys.Left: Keys.ControlShiftLeft, + Keys.Right: Keys.ControlShiftRight, + Keys.Up: Keys.ControlShiftUp, + Keys.Down: Keys.ControlShiftDown, + Keys.Home: Keys.ControlShiftHome, + Keys.End: Keys.ControlShiftEnd, + Keys.Insert: Keys.ControlShiftInsert, + Keys.PageUp: Keys.ControlShiftPageUp, + Keys.PageDown: Keys.ControlShiftPageDown, + } + result.key = mapping.get(result.key, result.key) + + # Correctly handle Control-Arrow/Home/End and Control-Insert/Delete keys. + if ( + control_key_state & self.LEFT_CTRL_PRESSED + or control_key_state & self.RIGHT_CTRL_PRESSED + ) and result: + mapping = { + Keys.Left: Keys.ControlLeft, + Keys.Right: Keys.ControlRight, + Keys.Up: Keys.ControlUp, + Keys.Down: Keys.ControlDown, + Keys.Home: Keys.ControlHome, + Keys.End: Keys.ControlEnd, + Keys.Insert: Keys.ControlInsert, + Keys.Delete: Keys.ControlDelete, + Keys.PageUp: Keys.ControlPageUp, + Keys.PageDown: Keys.ControlPageDown, + } + result.key = mapping.get(result.key, result.key) + + # Turn 'Tab' into 'BackTab' when shift was pressed. + # Also handle other shift-key combination + if control_key_state & self.SHIFT_PRESSED and result: + mapping = { + Keys.Tab: Keys.BackTab, + Keys.Left: Keys.ShiftLeft, + Keys.Right: Keys.ShiftRight, + Keys.Up: Keys.ShiftUp, + Keys.Down: Keys.ShiftDown, + Keys.Home: Keys.ShiftHome, + Keys.End: Keys.ShiftEnd, + Keys.Insert: Keys.ShiftInsert, + Keys.Delete: Keys.ShiftDelete, + Keys.PageUp: Keys.ShiftPageUp, + Keys.PageDown: Keys.ShiftPageDown, + } + result.key = mapping.get(result.key, result.key) + + # Turn 'Space' into 'ControlSpace' when control was pressed. + if ( + ( + control_key_state & self.LEFT_CTRL_PRESSED + or control_key_state & self.RIGHT_CTRL_PRESSED + ) + and result + and result.data == " " + ): + result = KeyPress(Keys.ControlSpace, " ") + + # Turn Control-Enter into META-Enter. (On a vt100 terminal, we cannot + # detect this combination. But it's really practical on Windows.) + if ( + ( + control_key_state & self.LEFT_CTRL_PRESSED + or control_key_state & self.RIGHT_CTRL_PRESSED + ) + and result + and result.key == Keys.ControlJ + ): + return [KeyPress(Keys.Escape, ""), result] + + # Return result. If alt was pressed, prefix the result with an + # 'Escape' key, just like unix VT100 terminals do. + + # NOTE: Only replace the left alt with escape. The right alt key often + # acts as altgr and is used in many non US keyboard layouts for + # typing some special characters, like a backslash. We don't want + # all backslashes to be prefixed with escape. (Esc-\ has a + # meaning in E-macs, for instance.) + if result: + meta_pressed = control_key_state & self.LEFT_ALT_PRESSED + + if meta_pressed: + return [KeyPress(Keys.Escape, ""), result] + else: + return [result] + + else: + return [] + + def _handle_mouse(self, ev: MOUSE_EVENT_RECORD) -> list[KeyPress]: + """ + Handle mouse events. Return a list of KeyPress instances. + """ + event_flags = ev.EventFlags + button_state = ev.ButtonState + + event_type: MouseEventType | None = None + button: MouseButton = MouseButton.NONE + + # Scroll events. + if event_flags & MOUSE_WHEELED: + if button_state > 0: + event_type = MouseEventType.SCROLL_UP + else: + event_type = MouseEventType.SCROLL_DOWN + else: + # Handle button state for non-scroll events. + if button_state == FROM_LEFT_1ST_BUTTON_PRESSED: + button = MouseButton.LEFT + + elif button_state == RIGHTMOST_BUTTON_PRESSED: + button = MouseButton.RIGHT + + # Move events. + if event_flags & MOUSE_MOVED: + event_type = MouseEventType.MOUSE_MOVE + + # No key pressed anymore: mouse up. + if event_type is None: + if button_state > 0: + # Some button pressed. + event_type = MouseEventType.MOUSE_DOWN + else: + # No button pressed. + event_type = MouseEventType.MOUSE_UP + + data = ";".join( + [ + button.value, + event_type.value, + str(ev.MousePosition.X), + str(ev.MousePosition.Y), + ] + ) + return [KeyPress(Keys.WindowsMouseEvent, data)] + + +class _Win32Handles: + """ + Utility to keep track of which handles are connectod to which callbacks. + + `add_win32_handle` starts a tiny event loop in another thread which waits + for the Win32 handle to become ready. When this happens, the callback will + be called in the current asyncio event loop using `call_soon_threadsafe`. + + `remove_win32_handle` will stop this tiny event loop. + + NOTE: We use this technique, so that we don't have to use the + `ProactorEventLoop` on Windows and we can wait for things like stdin + in a `SelectorEventLoop`. This is important, because our inputhook + mechanism (used by IPython), only works with the `SelectorEventLoop`. + """ + + def __init__(self) -> None: + self._handle_callbacks: dict[int, Callable[[], None]] = {} + + # Windows Events that are triggered when we have to stop watching this + # handle. + self._remove_events: dict[int, HANDLE] = {} + + def add_win32_handle(self, handle: HANDLE, callback: Callable[[], None]) -> None: + """ + Add a Win32 handle to the event loop. + """ + handle_value = handle.value + + if handle_value is None: + raise ValueError("Invalid handle.") + + # Make sure to remove a previous registered handler first. + self.remove_win32_handle(handle) + + loop = get_running_loop() + self._handle_callbacks[handle_value] = callback + + # Create remove event. + remove_event = create_win32_event() + self._remove_events[handle_value] = remove_event + + # Add reader. + def ready() -> None: + # Tell the callback that input's ready. + try: + callback() + finally: + run_in_executor_with_context(wait, loop=loop) + + # Wait for the input to become ready. + # (Use an executor for this, the Windows asyncio event loop doesn't + # allow us to wait for handles like stdin.) + def wait() -> None: + # Wait until either the handle becomes ready, or the remove event + # has been set. + result = wait_for_handles([remove_event, handle]) + + if result is remove_event: + windll.kernel32.CloseHandle(remove_event) + return + else: + loop.call_soon_threadsafe(ready) + + run_in_executor_with_context(wait, loop=loop) + + def remove_win32_handle(self, handle: HANDLE) -> Callable[[], None] | None: + """ + Remove a Win32 handle from the event loop. + Return either the registered handler or `None`. + """ + if handle.value is None: + return None # Ignore. + + # Trigger remove events, so that the reader knows to stop. + try: + event = self._remove_events.pop(handle.value) + except KeyError: + pass + else: + windll.kernel32.SetEvent(event) + + try: + return self._handle_callbacks.pop(handle.value) + except KeyError: + return None + + +@contextmanager +def attach_win32_input( + input: _Win32InputBase, callback: Callable[[], None] +) -> Iterator[None]: + """ + Context manager that makes this input active in the current event loop. + + :param input: :class:`~prompt_toolkit.input.Input` object. + :param input_ready_callback: Called when the input is ready to read. + """ + win32_handles = input.win32_handles + handle = input.handle + + if handle.value is None: + raise ValueError("Invalid handle.") + + # Add reader. + previous_callback = win32_handles.remove_win32_handle(handle) + win32_handles.add_win32_handle(handle, callback) + + try: + yield + finally: + win32_handles.remove_win32_handle(handle) + + if previous_callback: + win32_handles.add_win32_handle(handle, previous_callback) + + +@contextmanager +def detach_win32_input(input: _Win32InputBase) -> Iterator[None]: + win32_handles = input.win32_handles + handle = input.handle + + if handle.value is None: + raise ValueError("Invalid handle.") + + previous_callback = win32_handles.remove_win32_handle(handle) + + try: + yield + finally: + if previous_callback: + win32_handles.add_win32_handle(handle, previous_callback) + + +class raw_mode: + """ + :: + + with raw_mode(stdin): + ''' the windows terminal is now in 'raw' mode. ''' + + The ``fileno`` attribute is ignored. This is to be compatible with the + `raw_input` method of `.vt100_input`. + """ + + def __init__(self, fileno: int | None = None) -> None: + self.handle = HANDLE(windll.kernel32.GetStdHandle(STD_INPUT_HANDLE)) + + def __enter__(self) -> None: + # Remember original mode. + original_mode = DWORD() + windll.kernel32.GetConsoleMode(self.handle, pointer(original_mode)) + self.original_mode = original_mode + + self._patch() + + def _patch(self) -> None: + # Set raw + ENABLE_ECHO_INPUT = 0x0004 + 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), + ) + + def __exit__(self, *a: object) -> None: + # Restore original mode + windll.kernel32.SetConsoleMode(self.handle, self.original_mode) + + +class cooked_mode(raw_mode): + """ + :: + + with cooked_mode(stdin): + ''' The pseudo-terminal stdin is now used in cooked mode. ''' + """ + + def _patch(self) -> None: + # Set cooked. + ENABLE_ECHO_INPUT = 0x0004 + 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), + ) diff --git a/src/prompt_toolkit/input/win32_pipe.py b/src/prompt_toolkit/input/win32_pipe.py new file mode 100644 index 0000000..0bafa49 --- /dev/null +++ b/src/prompt_toolkit/input/win32_pipe.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import sys + +assert sys.platform == "win32" + +from contextlib import contextmanager +from ctypes import windll +from ctypes.wintypes import HANDLE +from typing import Callable, ContextManager, Iterator + +from prompt_toolkit.eventloop.win32 import create_win32_event + +from ..key_binding import KeyPress +from ..utils import DummyContext +from .base import PipeInput +from .vt100_parser import Vt100Parser +from .win32 import _Win32InputBase, attach_win32_input, detach_win32_input + +__all__ = ["Win32PipeInput"] + + +class Win32PipeInput(_Win32InputBase, PipeInput): + """ + This is an input pipe that works on Windows. + Text or bytes can be feed into the pipe, and key strokes can be read from + the pipe. This is useful if we want to send the input programmatically into + the application. Mostly useful for unit testing. + + Notice that even though it's Windows, we use vt100 escape sequences over + the pipe. + + Usage:: + + input = Win32PipeInput() + input.send_text('inputdata') + """ + + _id = 0 + + def __init__(self, _event: HANDLE) -> None: + super().__init__() + # Event (handle) for registering this input in the event loop. + # This event is set when there is data available to read from the pipe. + # Note: We use this approach instead of using a regular pipe, like + # returned from `os.pipe()`, because making such a regular pipe + # non-blocking is tricky and this works really well. + self._event = create_win32_event() + + self._closed = False + + # Parser for incoming keys. + self._buffer: list[KeyPress] = [] # Buffer to collect the Key objects. + self.vt100_parser = Vt100Parser(lambda key: self._buffer.append(key)) + + # Identifier for every PipeInput for the hash. + self.__class__._id += 1 + self._id = self.__class__._id + + @classmethod + @contextmanager + def create(cls) -> Iterator[Win32PipeInput]: + event = create_win32_event() + try: + yield Win32PipeInput(_event=event) + finally: + windll.kernel32.CloseHandle(event) + + @property + def closed(self) -> bool: + return self._closed + + def fileno(self) -> int: + """ + The windows pipe doesn't depend on the file handle. + """ + raise NotImplementedError + + @property + def handle(self) -> HANDLE: + "The handle used for registering this pipe in the event loop." + return self._event + + def attach(self, input_ready_callback: Callable[[], None]) -> ContextManager[None]: + """ + Return a context manager that makes this input active in the current + event loop. + """ + return attach_win32_input(self, input_ready_callback) + + def detach(self) -> ContextManager[None]: + """ + Return a context manager that makes sure that this input is not active + in the current event loop. + """ + return detach_win32_input(self) + + def read_keys(self) -> list[KeyPress]: + "Read list of KeyPress." + + # Return result. + result = self._buffer + self._buffer = [] + + # Reset event. + if not self._closed: + # (If closed, the event should not reset.) + windll.kernel32.ResetEvent(self._event) + + return result + + def flush_keys(self) -> list[KeyPress]: + """ + Flush pending keys and return them. + (Used for flushing the 'escape' key.) + """ + # Flush all pending keys. (This is most important to flush the vt100 + # 'Escape' key early when nothing else follows.) + self.vt100_parser.flush() + + # Return result. + result = self._buffer + self._buffer = [] + return result + + def send_bytes(self, data: bytes) -> None: + "Send bytes to the input." + self.send_text(data.decode("utf-8", "ignore")) + + def send_text(self, text: str) -> None: + "Send text to the input." + if self._closed: + raise ValueError("Attempt to write into a closed pipe.") + + # Pass it through our vt100 parser. + self.vt100_parser.feed(text) + + # Set event. + windll.kernel32.SetEvent(self._event) + + def raw_mode(self) -> ContextManager[None]: + return DummyContext() + + def cooked_mode(self) -> ContextManager[None]: + return DummyContext() + + def close(self) -> None: + "Close write-end of the pipe." + self._closed = True + windll.kernel32.SetEvent(self._event) + + def typeahead_hash(self) -> str: + """ + This needs to be unique for every `PipeInput`. + """ + return f"pipe-input-{self._id}" diff --git a/src/prompt_toolkit/key_binding/__init__.py b/src/prompt_toolkit/key_binding/__init__.py new file mode 100644 index 0000000..c31746a --- /dev/null +++ b/src/prompt_toolkit/key_binding/__init__.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from .key_bindings import ( + ConditionalKeyBindings, + DynamicKeyBindings, + KeyBindings, + KeyBindingsBase, + merge_key_bindings, +) +from .key_processor import KeyPress, KeyPressEvent + +__all__ = [ + # key_bindings. + "ConditionalKeyBindings", + "DynamicKeyBindings", + "KeyBindings", + "KeyBindingsBase", + "merge_key_bindings", + # key_processor + "KeyPress", + "KeyPressEvent", +] diff --git a/src/prompt_toolkit/key_binding/bindings/__init__.py b/src/prompt_toolkit/key_binding/bindings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/prompt_toolkit/key_binding/bindings/auto_suggest.py b/src/prompt_toolkit/key_binding/bindings/auto_suggest.py new file mode 100644 index 0000000..3d8a843 --- /dev/null +++ b/src/prompt_toolkit/key_binding/bindings/auto_suggest.py @@ -0,0 +1,65 @@ +""" +Key bindings for auto suggestion (for fish-style auto suggestion). +""" +from __future__ import annotations + +import re + +from prompt_toolkit.application.current import get_app +from prompt_toolkit.filters import Condition, emacs_mode +from prompt_toolkit.key_binding.key_bindings import KeyBindings +from prompt_toolkit.key_binding.key_processor import KeyPressEvent + +__all__ = [ + "load_auto_suggest_bindings", +] + +E = KeyPressEvent + + +def load_auto_suggest_bindings() -> KeyBindings: + """ + Key bindings for accepting auto suggestion text. + + (This has to come after the Vi bindings, because they also have an + implementation for the "right arrow", but we really want the suggestion + binding when a suggestion is available.) + """ + key_bindings = KeyBindings() + handle = key_bindings.add + + @Condition + def suggestion_available() -> bool: + app = get_app() + return ( + app.current_buffer.suggestion is not None + and len(app.current_buffer.suggestion.text) > 0 + and app.current_buffer.document.is_cursor_at_the_end + ) + + @handle("c-f", filter=suggestion_available) + @handle("c-e", filter=suggestion_available) + @handle("right", filter=suggestion_available) + def _accept(event: E) -> None: + """ + Accept suggestion. + """ + b = event.current_buffer + suggestion = b.suggestion + + if suggestion: + b.insert_text(suggestion.text) + + @handle("escape", "f", filter=suggestion_available & emacs_mode) + def _fill(event: E) -> None: + """ + Fill partial suggestion. + """ + b = event.current_buffer + suggestion = b.suggestion + + if suggestion: + t = re.split(r"([^\s/]+(?:\s+|/))", suggestion.text) + b.insert_text(next(x for x in t if x)) + + return key_bindings diff --git a/src/prompt_toolkit/key_binding/bindings/basic.py b/src/prompt_toolkit/key_binding/bindings/basic.py new file mode 100644 index 0000000..084548d --- /dev/null +++ b/src/prompt_toolkit/key_binding/bindings/basic.py @@ -0,0 +1,255 @@ +# pylint: disable=function-redefined +from __future__ import annotations + +from prompt_toolkit.application.current import get_app +from prompt_toolkit.filters import ( + Condition, + emacs_insert_mode, + has_selection, + in_paste_mode, + is_multiline, + vi_insert_mode, +) +from prompt_toolkit.key_binding.key_processor import KeyPress, KeyPressEvent +from prompt_toolkit.keys import Keys + +from ..key_bindings import KeyBindings +from .named_commands import get_by_name + +__all__ = [ + "load_basic_bindings", +] + +E = KeyPressEvent + + +def if_no_repeat(event: E) -> bool: + """Callable that returns True when the previous event was delivered to + another handler.""" + return not event.is_repeat + + +def load_basic_bindings() -> KeyBindings: + key_bindings = KeyBindings() + insert_mode = vi_insert_mode | emacs_insert_mode + handle = key_bindings.add + + @handle("c-a") + @handle("c-b") + @handle("c-c") + @handle("c-d") + @handle("c-e") + @handle("c-f") + @handle("c-g") + @handle("c-h") + @handle("c-i") + @handle("c-j") + @handle("c-k") + @handle("c-l") + @handle("c-m") + @handle("c-n") + @handle("c-o") + @handle("c-p") + @handle("c-q") + @handle("c-r") + @handle("c-s") + @handle("c-t") + @handle("c-u") + @handle("c-v") + @handle("c-w") + @handle("c-x") + @handle("c-y") + @handle("c-z") + @handle("f1") + @handle("f2") + @handle("f3") + @handle("f4") + @handle("f5") + @handle("f6") + @handle("f7") + @handle("f8") + @handle("f9") + @handle("f10") + @handle("f11") + @handle("f12") + @handle("f13") + @handle("f14") + @handle("f15") + @handle("f16") + @handle("f17") + @handle("f18") + @handle("f19") + @handle("f20") + @handle("f21") + @handle("f22") + @handle("f23") + @handle("f24") + @handle("c-@") # Also c-space. + @handle("c-\\") + @handle("c-]") + @handle("c-^") + @handle("c-_") + @handle("backspace") + @handle("up") + @handle("down") + @handle("right") + @handle("left") + @handle("s-up") + @handle("s-down") + @handle("s-right") + @handle("s-left") + @handle("home") + @handle("end") + @handle("s-home") + @handle("s-end") + @handle("delete") + @handle("s-delete") + @handle("c-delete") + @handle("pageup") + @handle("pagedown") + @handle("s-tab") + @handle("tab") + @handle("c-s-left") + @handle("c-s-right") + @handle("c-s-home") + @handle("c-s-end") + @handle("c-left") + @handle("c-right") + @handle("c-up") + @handle("c-down") + @handle("c-home") + @handle("c-end") + @handle("insert") + @handle("s-insert") + @handle("c-insert") + @handle("<sigint>") + @handle(Keys.Ignore) + def _ignore(event: E) -> None: + """ + First, for any of these keys, Don't do anything by default. Also don't + catch them in the 'Any' handler which will insert them as data. + + If people want to insert these characters as a literal, they can always + do by doing a quoted insert. (ControlQ in emacs mode, ControlV in Vi + mode.) + """ + pass + + # Readline-style bindings. + handle("home")(get_by_name("beginning-of-line")) + handle("end")(get_by_name("end-of-line")) + handle("left")(get_by_name("backward-char")) + handle("right")(get_by_name("forward-char")) + handle("c-up")(get_by_name("previous-history")) + handle("c-down")(get_by_name("next-history")) + handle("c-l")(get_by_name("clear-screen")) + + handle("c-k", filter=insert_mode)(get_by_name("kill-line")) + handle("c-u", filter=insert_mode)(get_by_name("unix-line-discard")) + handle("backspace", filter=insert_mode, save_before=if_no_repeat)( + get_by_name("backward-delete-char") + ) + handle("delete", filter=insert_mode, save_before=if_no_repeat)( + get_by_name("delete-char") + ) + handle("c-delete", filter=insert_mode, save_before=if_no_repeat)( + get_by_name("delete-char") + ) + handle(Keys.Any, filter=insert_mode, save_before=if_no_repeat)( + get_by_name("self-insert") + ) + handle("c-t", filter=insert_mode)(get_by_name("transpose-chars")) + handle("c-i", filter=insert_mode)(get_by_name("menu-complete")) + handle("s-tab", filter=insert_mode)(get_by_name("menu-complete-backward")) + + # Control-W should delete, using whitespace as separator, while M-Del + # should delete using [^a-zA-Z0-9] as a boundary. + handle("c-w", filter=insert_mode)(get_by_name("unix-word-rubout")) + + handle("pageup", filter=~has_selection)(get_by_name("previous-history")) + handle("pagedown", filter=~has_selection)(get_by_name("next-history")) + + # CTRL keys. + + @Condition + def has_text_before_cursor() -> bool: + return bool(get_app().current_buffer.text) + + handle("c-d", filter=has_text_before_cursor & insert_mode)( + get_by_name("delete-char") + ) + + @handle("enter", filter=insert_mode & is_multiline) + def _newline(event: E) -> None: + """ + Newline (in case of multiline input. + """ + event.current_buffer.newline(copy_margin=not in_paste_mode()) + + @handle("c-j") + def _newline2(event: E) -> None: + r""" + By default, handle \n as if it were a \r (enter). + (It appears that some terminals send \n instead of \r when pressing + enter. - at least the Linux subsystem for Windows.) + """ + event.key_processor.feed(KeyPress(Keys.ControlM, "\r"), first=True) + + # Delete the word before the cursor. + + @handle("up") + def _go_up(event: E) -> None: + event.current_buffer.auto_up(count=event.arg) + + @handle("down") + def _go_down(event: E) -> None: + event.current_buffer.auto_down(count=event.arg) + + @handle("delete", filter=has_selection) + def _cut(event: E) -> None: + data = event.current_buffer.cut_selection() + event.app.clipboard.set_data(data) + + # Global bindings. + + @handle("c-z") + def _insert_ctrl_z(event: E) -> None: + """ + By default, control-Z should literally insert Ctrl-Z. + (Ansi Ctrl-Z, code 26 in MSDOS means End-Of-File. + In a Python REPL for instance, it's possible to type + Control-Z followed by enter to quit.) + + When the system bindings are loaded and suspend-to-background is + supported, that will override this binding. + """ + event.current_buffer.insert_text(event.data) + + @handle(Keys.BracketedPaste) + def _paste(event: E) -> None: + """ + Pasting from clipboard. + """ + data = event.data + + # Be sure to use \n as line ending. + # Some terminals (Like iTerm2) seem to paste \r\n line endings in a + # bracketed paste. See: https://github.com/ipython/ipython/issues/9737 + data = data.replace("\r\n", "\n") + data = data.replace("\r", "\n") + + event.current_buffer.insert_text(data) + + @Condition + def in_quoted_insert() -> bool: + return get_app().quoted_insert + + @handle(Keys.Any, filter=in_quoted_insert, eager=True) + def _insert_text(event: E) -> None: + """ + Handle quoted insert. + """ + event.current_buffer.insert_text(event.data, overwrite=False) + event.app.quoted_insert = False + + return key_bindings diff --git a/src/prompt_toolkit/key_binding/bindings/completion.py b/src/prompt_toolkit/key_binding/bindings/completion.py new file mode 100644 index 0000000..016821f --- /dev/null +++ b/src/prompt_toolkit/key_binding/bindings/completion.py @@ -0,0 +1,205 @@ +""" +Key binding handlers for displaying completions. +""" +from __future__ import annotations + +import asyncio +import math +from typing import TYPE_CHECKING + +from prompt_toolkit.application.run_in_terminal import in_terminal +from prompt_toolkit.completion import ( + CompleteEvent, + Completion, + get_common_complete_suffix, +) +from prompt_toolkit.formatted_text import StyleAndTextTuples +from prompt_toolkit.key_binding.key_bindings import KeyBindings +from prompt_toolkit.key_binding.key_processor import KeyPressEvent +from prompt_toolkit.keys import Keys +from prompt_toolkit.utils import get_cwidth + +if TYPE_CHECKING: + from prompt_toolkit.application import Application + from prompt_toolkit.shortcuts import PromptSession + +__all__ = [ + "generate_completions", + "display_completions_like_readline", +] + +E = KeyPressEvent + + +def generate_completions(event: E) -> None: + r""" + Tab-completion: where the first tab completes the common suffix and the + second tab lists all the completions. + """ + b = event.current_buffer + + # When already navigating through completions, select the next one. + if b.complete_state: + b.complete_next() + else: + b.start_completion(insert_common_part=True) + + +def display_completions_like_readline(event: E) -> None: + """ + Key binding handler for readline-style tab completion. + This is meant to be as similar as possible to the way how readline displays + completions. + + Generate the completions immediately (blocking) and display them above the + prompt in columns. + + Usage:: + + # Call this handler when 'Tab' has been pressed. + key_bindings.add(Keys.ControlI)(display_completions_like_readline) + """ + # Request completions. + b = event.current_buffer + if b.completer is None: + return + complete_event = CompleteEvent(completion_requested=True) + completions = list(b.completer.get_completions(b.document, complete_event)) + + # Calculate the common suffix. + common_suffix = get_common_complete_suffix(b.document, completions) + + # One completion: insert it. + if len(completions) == 1: + b.delete_before_cursor(-completions[0].start_position) + b.insert_text(completions[0].text) + # Multiple completions with common part. + elif common_suffix: + b.insert_text(common_suffix) + # Otherwise: display all completions. + elif completions: + _display_completions_like_readline(event.app, completions) + + +def _display_completions_like_readline( + app: Application[object], completions: list[Completion] +) -> asyncio.Task[None]: + """ + Display the list of completions in columns above the prompt. + This will ask for a confirmation if there are too many completions to fit + on a single page and provide a paginator to walk through them. + """ + from prompt_toolkit.formatted_text import to_formatted_text + from prompt_toolkit.shortcuts.prompt import create_confirm_session + + # Get terminal dimensions. + term_size = app.output.get_size() + term_width = term_size.columns + term_height = term_size.rows + + # Calculate amount of required columns/rows for displaying the + # completions. (Keep in mind that completions are displayed + # alphabetically column-wise.) + max_compl_width = min( + term_width, max(get_cwidth(c.display_text) for c in completions) + 1 + ) + column_count = max(1, term_width // max_compl_width) + completions_per_page = column_count * (term_height - 1) + page_count = int(math.ceil(len(completions) / float(completions_per_page))) + # Note: math.ceil can return float on Python2. + + def display(page: int) -> None: + # Display completions. + page_completions = completions[ + page * completions_per_page : (page + 1) * completions_per_page + ] + + page_row_count = int(math.ceil(len(page_completions) / float(column_count))) + page_columns = [ + page_completions[i * page_row_count : (i + 1) * page_row_count] + for i in range(column_count) + ] + + result: StyleAndTextTuples = [] + + for r in range(page_row_count): + for c in range(column_count): + try: + completion = page_columns[c][r] + style = "class:readline-like-completions.completion " + ( + completion.style or "" + ) + + result.extend(to_formatted_text(completion.display, style=style)) + + # Add padding. + padding = max_compl_width - get_cwidth(completion.display_text) + result.append((completion.style, " " * padding)) + except IndexError: + pass + result.append(("", "\n")) + + app.print_text(to_formatted_text(result, "class:readline-like-completions")) + + # User interaction through an application generator function. + async def run_compl() -> None: + "Coroutine." + async with in_terminal(render_cli_done=True): + if len(completions) > completions_per_page: + # Ask confirmation if it doesn't fit on the screen. + confirm = await create_confirm_session( + f"Display all {len(completions)} possibilities?", + ).prompt_async() + + if confirm: + # Display pages. + for page in range(page_count): + display(page) + + if page != page_count - 1: + # Display --MORE-- and go to the next page. + show_more = await _create_more_session( + "--MORE--" + ).prompt_async() + + if not show_more: + return + else: + app.output.flush() + else: + # Display all completions. + display(0) + + return app.create_background_task(run_compl()) + + +def _create_more_session(message: str = "--MORE--") -> PromptSession[bool]: + """ + Create a `PromptSession` object for displaying the "--MORE--". + """ + from prompt_toolkit.shortcuts import PromptSession + + bindings = KeyBindings() + + @bindings.add(" ") + @bindings.add("y") + @bindings.add("Y") + @bindings.add(Keys.ControlJ) + @bindings.add(Keys.ControlM) + @bindings.add(Keys.ControlI) # Tab. + def _yes(event: E) -> None: + event.app.exit(result=True) + + @bindings.add("n") + @bindings.add("N") + @bindings.add("q") + @bindings.add("Q") + @bindings.add(Keys.ControlC) + def _no(event: E) -> None: + event.app.exit(result=False) + + @bindings.add(Keys.Any) + def _ignore(event: E) -> None: + "Disable inserting of text." + + return PromptSession(message, key_bindings=bindings, erase_when_done=True) diff --git a/src/prompt_toolkit/key_binding/bindings/cpr.py b/src/prompt_toolkit/key_binding/bindings/cpr.py new file mode 100644 index 0000000..cd9df0a --- /dev/null +++ b/src/prompt_toolkit/key_binding/bindings/cpr.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from prompt_toolkit.key_binding.key_processor import KeyPressEvent +from prompt_toolkit.keys import Keys + +from ..key_bindings import KeyBindings + +__all__ = [ + "load_cpr_bindings", +] + +E = KeyPressEvent + + +def load_cpr_bindings() -> KeyBindings: + key_bindings = KeyBindings() + + @key_bindings.add(Keys.CPRResponse, save_before=lambda e: False) + def _(event: E) -> None: + """ + Handle incoming Cursor-Position-Request response. + """ + # The incoming data looks like u'\x1b[35;1R' + # Parse row/col information. + row, col = map(int, event.data[2:-1].split(";")) + + # Report absolute cursor position to the renderer. + event.app.renderer.report_absolute_cursor_row(row) + + return key_bindings diff --git a/src/prompt_toolkit/key_binding/bindings/emacs.py b/src/prompt_toolkit/key_binding/bindings/emacs.py new file mode 100644 index 0000000..80a66fd --- /dev/null +++ b/src/prompt_toolkit/key_binding/bindings/emacs.py @@ -0,0 +1,557 @@ +# pylint: disable=function-redefined +from __future__ import annotations + +from prompt_toolkit.application.current import get_app +from prompt_toolkit.buffer import Buffer, indent, unindent +from prompt_toolkit.completion import CompleteEvent +from prompt_toolkit.filters import ( + Condition, + emacs_insert_mode, + emacs_mode, + has_arg, + has_selection, + in_paste_mode, + is_multiline, + is_read_only, + shift_selection_mode, + vi_search_direction_reversed, +) +from prompt_toolkit.key_binding.key_bindings import Binding +from prompt_toolkit.key_binding.key_processor import KeyPressEvent +from prompt_toolkit.keys import Keys +from prompt_toolkit.selection import SelectionType + +from ..key_bindings import ConditionalKeyBindings, KeyBindings, KeyBindingsBase +from .named_commands import get_by_name + +__all__ = [ + "load_emacs_bindings", + "load_emacs_search_bindings", + "load_emacs_shift_selection_bindings", +] + +E = KeyPressEvent + + +def load_emacs_bindings() -> KeyBindingsBase: + """ + Some e-macs extensions. + """ + # Overview of Readline emacs commands: + # http://www.catonmat.net/download/readline-emacs-editing-mode-cheat-sheet.pdf + key_bindings = KeyBindings() + handle = key_bindings.add + + insert_mode = emacs_insert_mode + + @handle("escape") + def _esc(event: E) -> None: + """ + By default, ignore escape key. + + (If we don't put this here, and Esc is followed by a key which sequence + is not handled, we'll insert an Escape character in the input stream. + Something we don't want and happens to easily in emacs mode. + Further, people can always use ControlQ to do a quoted insert.) + """ + pass + + handle("c-a")(get_by_name("beginning-of-line")) + handle("c-b")(get_by_name("backward-char")) + handle("c-delete", filter=insert_mode)(get_by_name("kill-word")) + handle("c-e")(get_by_name("end-of-line")) + handle("c-f")(get_by_name("forward-char")) + handle("c-left")(get_by_name("backward-word")) + handle("c-right")(get_by_name("forward-word")) + handle("c-x", "r", "y", filter=insert_mode)(get_by_name("yank")) + handle("c-y", filter=insert_mode)(get_by_name("yank")) + handle("escape", "b")(get_by_name("backward-word")) + handle("escape", "c", filter=insert_mode)(get_by_name("capitalize-word")) + handle("escape", "d", filter=insert_mode)(get_by_name("kill-word")) + handle("escape", "f")(get_by_name("forward-word")) + handle("escape", "l", filter=insert_mode)(get_by_name("downcase-word")) + handle("escape", "u", filter=insert_mode)(get_by_name("uppercase-word")) + handle("escape", "y", filter=insert_mode)(get_by_name("yank-pop")) + handle("escape", "backspace", filter=insert_mode)(get_by_name("backward-kill-word")) + handle("escape", "\\", filter=insert_mode)(get_by_name("delete-horizontal-space")) + + handle("c-home")(get_by_name("beginning-of-buffer")) + handle("c-end")(get_by_name("end-of-buffer")) + + handle("c-_", save_before=(lambda e: False), filter=insert_mode)( + get_by_name("undo") + ) + + handle("c-x", "c-u", save_before=(lambda e: False), filter=insert_mode)( + get_by_name("undo") + ) + + handle("escape", "<", filter=~has_selection)(get_by_name("beginning-of-history")) + handle("escape", ">", filter=~has_selection)(get_by_name("end-of-history")) + + handle("escape", ".", filter=insert_mode)(get_by_name("yank-last-arg")) + handle("escape", "_", filter=insert_mode)(get_by_name("yank-last-arg")) + handle("escape", "c-y", filter=insert_mode)(get_by_name("yank-nth-arg")) + handle("escape", "#", filter=insert_mode)(get_by_name("insert-comment")) + handle("c-o")(get_by_name("operate-and-get-next")) + + # ControlQ does a quoted insert. Not that for vt100 terminals, you have to + # disable flow control by running ``stty -ixon``, otherwise Ctrl-Q and + # Ctrl-S are captured by the terminal. + handle("c-q", filter=~has_selection)(get_by_name("quoted-insert")) + + handle("c-x", "(")(get_by_name("start-kbd-macro")) + handle("c-x", ")")(get_by_name("end-kbd-macro")) + handle("c-x", "e")(get_by_name("call-last-kbd-macro")) + + @handle("c-n") + def _next(event: E) -> None: + "Next line." + event.current_buffer.auto_down() + + @handle("c-p") + def _prev(event: E) -> None: + "Previous line." + event.current_buffer.auto_up(count=event.arg) + + def handle_digit(c: str) -> None: + """ + Handle input of arguments. + The first number needs to be preceded by escape. + """ + + @handle(c, filter=has_arg) + @handle("escape", c) + def _(event: E) -> None: + event.append_to_arg_count(c) + + for c in "0123456789": + handle_digit(c) + + @handle("escape", "-", filter=~has_arg) + def _meta_dash(event: E) -> None: + """""" + if event._arg is None: + event.append_to_arg_count("-") + + @handle("-", filter=Condition(lambda: get_app().key_processor.arg == "-")) + def _dash(event: E) -> None: + """ + When '-' is typed again, after exactly '-' has been given as an + argument, ignore this. + """ + event.app.key_processor.arg = "-" + + @Condition + def is_returnable() -> bool: + return get_app().current_buffer.is_returnable + + # Meta + Enter: always accept input. + handle("escape", "enter", filter=insert_mode & is_returnable)( + get_by_name("accept-line") + ) + + # Enter: accept input in single line mode. + handle("enter", filter=insert_mode & is_returnable & ~is_multiline)( + get_by_name("accept-line") + ) + + def character_search(buff: Buffer, char: str, count: int) -> None: + if count < 0: + match = buff.document.find_backwards( + char, in_current_line=True, count=-count + ) + else: + match = buff.document.find(char, in_current_line=True, count=count) + + if match is not None: + buff.cursor_position += match + + @handle("c-]", Keys.Any) + def _goto_char(event: E) -> None: + "When Ctl-] + a character is pressed. go to that character." + # Also named 'character-search' + character_search(event.current_buffer, event.data, event.arg) + + @handle("escape", "c-]", Keys.Any) + def _goto_char_backwards(event: E) -> None: + "Like Ctl-], but backwards." + # Also named 'character-search-backward' + character_search(event.current_buffer, event.data, -event.arg) + + @handle("escape", "a") + def _prev_sentence(event: E) -> None: + "Previous sentence." + # TODO: + + @handle("escape", "e") + def _end_of_sentence(event: E) -> None: + "Move to end of sentence." + # TODO: + + @handle("escape", "t", filter=insert_mode) + def _swap_characters(event: E) -> None: + """ + Swap the last two words before the cursor. + """ + # TODO + + @handle("escape", "*", filter=insert_mode) + def _insert_all_completions(event: E) -> None: + """ + `meta-*`: Insert all possible completions of the preceding text. + """ + buff = event.current_buffer + + # List all completions. + complete_event = CompleteEvent(text_inserted=False, completion_requested=True) + completions = list( + buff.completer.get_completions(buff.document, complete_event) + ) + + # Insert them. + text_to_insert = " ".join(c.text for c in completions) + buff.insert_text(text_to_insert) + + @handle("c-x", "c-x") + def _toggle_start_end(event: E) -> None: + """ + Move cursor back and forth between the start and end of the current + line. + """ + buffer = event.current_buffer + + if buffer.document.is_cursor_at_the_end_of_line: + buffer.cursor_position += buffer.document.get_start_of_line_position( + after_whitespace=False + ) + else: + buffer.cursor_position += buffer.document.get_end_of_line_position() + + @handle("c-@") # Control-space or Control-@ + def _start_selection(event: E) -> None: + """ + Start of the selection (if the current buffer is not empty). + """ + # Take the current cursor position as the start of this selection. + buff = event.current_buffer + if buff.text: + buff.start_selection(selection_type=SelectionType.CHARACTERS) + + @handle("c-g", filter=~has_selection) + def _cancel(event: E) -> None: + """ + Control + G: Cancel completion menu and validation state. + """ + event.current_buffer.complete_state = None + event.current_buffer.validation_error = None + + @handle("c-g", filter=has_selection) + def _cancel_selection(event: E) -> None: + """ + Cancel selection. + """ + event.current_buffer.exit_selection() + + @handle("c-w", filter=has_selection) + @handle("c-x", "r", "k", filter=has_selection) + def _cut(event: E) -> None: + """ + Cut selected text. + """ + data = event.current_buffer.cut_selection() + event.app.clipboard.set_data(data) + + @handle("escape", "w", filter=has_selection) + def _copy(event: E) -> None: + """ + Copy selected text. + """ + data = event.current_buffer.copy_selection() + event.app.clipboard.set_data(data) + + @handle("escape", "left") + def _start_of_word(event: E) -> None: + """ + Cursor to start of previous word. + """ + buffer = event.current_buffer + buffer.cursor_position += ( + buffer.document.find_previous_word_beginning(count=event.arg) or 0 + ) + + @handle("escape", "right") + def _start_next_word(event: E) -> None: + """ + Cursor to start of next word. + """ + buffer = event.current_buffer + buffer.cursor_position += ( + buffer.document.find_next_word_beginning(count=event.arg) + or buffer.document.get_end_of_document_position() + ) + + @handle("escape", "/", filter=insert_mode) + def _complete(event: E) -> None: + """ + M-/: Complete. + """ + b = event.current_buffer + if b.complete_state: + b.complete_next() + else: + b.start_completion(select_first=True) + + @handle("c-c", ">", filter=has_selection) + def _indent(event: E) -> None: + """ + Indent selected text. + """ + buffer = event.current_buffer + + buffer.cursor_position += buffer.document.get_start_of_line_position( + after_whitespace=True + ) + + from_, to = buffer.document.selection_range() + from_, _ = buffer.document.translate_index_to_position(from_) + to, _ = buffer.document.translate_index_to_position(to) + + indent(buffer, from_, to + 1, count=event.arg) + + @handle("c-c", "<", filter=has_selection) + def _unindent(event: E) -> None: + """ + Unindent selected text. + """ + buffer = event.current_buffer + + from_, to = buffer.document.selection_range() + from_, _ = buffer.document.translate_index_to_position(from_) + to, _ = buffer.document.translate_index_to_position(to) + + unindent(buffer, from_, to + 1, count=event.arg) + + return ConditionalKeyBindings(key_bindings, emacs_mode) + + +def load_emacs_search_bindings() -> KeyBindingsBase: + key_bindings = KeyBindings() + handle = key_bindings.add + from . import search + + # NOTE: We don't bind 'Escape' to 'abort_search'. The reason is that we + # want Alt+Enter to accept input directly in incremental search mode. + # Instead, we have double escape. + + handle("c-r")(search.start_reverse_incremental_search) + handle("c-s")(search.start_forward_incremental_search) + + handle("c-c")(search.abort_search) + handle("c-g")(search.abort_search) + handle("c-r")(search.reverse_incremental_search) + handle("c-s")(search.forward_incremental_search) + handle("up")(search.reverse_incremental_search) + handle("down")(search.forward_incremental_search) + handle("enter")(search.accept_search) + + # Handling of escape. + handle("escape", eager=True)(search.accept_search) + + # Like Readline, it's more natural to accept the search when escape has + # been pressed, however instead the following two bindings could be used + # instead. + # #handle('escape', 'escape', eager=True)(search.abort_search) + # #handle('escape', 'enter', eager=True)(search.accept_search_and_accept_input) + + # If Read-only: also include the following key bindings: + + # '/' and '?' key bindings for searching, just like Vi mode. + handle("?", filter=is_read_only & ~vi_search_direction_reversed)( + search.start_reverse_incremental_search + ) + handle("/", filter=is_read_only & ~vi_search_direction_reversed)( + search.start_forward_incremental_search + ) + handle("?", filter=is_read_only & vi_search_direction_reversed)( + search.start_forward_incremental_search + ) + handle("/", filter=is_read_only & vi_search_direction_reversed)( + search.start_reverse_incremental_search + ) + + @handle("n", filter=is_read_only) + def _jump_next(event: E) -> None: + "Jump to next match." + event.current_buffer.apply_search( + event.app.current_search_state, + include_current_position=False, + count=event.arg, + ) + + @handle("N", filter=is_read_only) + def _jump_prev(event: E) -> None: + "Jump to previous match." + event.current_buffer.apply_search( + ~event.app.current_search_state, + include_current_position=False, + count=event.arg, + ) + + return ConditionalKeyBindings(key_bindings, emacs_mode) + + +def load_emacs_shift_selection_bindings() -> KeyBindingsBase: + """ + Bindings to select text with shift + cursor movements + """ + + key_bindings = KeyBindings() + handle = key_bindings.add + + def unshift_move(event: E) -> None: + """ + Used for the shift selection mode. When called with + a shift + movement key press event, moves the cursor + as if shift is not pressed. + """ + key = event.key_sequence[0].key + + if key == Keys.ShiftUp: + event.current_buffer.auto_up(count=event.arg) + return + if key == Keys.ShiftDown: + event.current_buffer.auto_down(count=event.arg) + return + + # the other keys are handled through their readline command + key_to_command: dict[Keys | str, str] = { + Keys.ShiftLeft: "backward-char", + Keys.ShiftRight: "forward-char", + Keys.ShiftHome: "beginning-of-line", + Keys.ShiftEnd: "end-of-line", + Keys.ControlShiftLeft: "backward-word", + Keys.ControlShiftRight: "forward-word", + Keys.ControlShiftHome: "beginning-of-buffer", + Keys.ControlShiftEnd: "end-of-buffer", + } + + try: + # Both the dict lookup and `get_by_name` can raise KeyError. + binding = get_by_name(key_to_command[key]) + except KeyError: + pass + else: # (`else` is not really needed here.) + if isinstance(binding, Binding): + # (It should always be a binding here) + binding.call(event) + + @handle("s-left", filter=~has_selection) + @handle("s-right", filter=~has_selection) + @handle("s-up", filter=~has_selection) + @handle("s-down", filter=~has_selection) + @handle("s-home", filter=~has_selection) + @handle("s-end", filter=~has_selection) + @handle("c-s-left", filter=~has_selection) + @handle("c-s-right", filter=~has_selection) + @handle("c-s-home", filter=~has_selection) + @handle("c-s-end", filter=~has_selection) + def _start_selection(event: E) -> None: + """ + Start selection with shift + movement. + """ + # Take the current cursor position as the start of this selection. + buff = event.current_buffer + if buff.text: + buff.start_selection(selection_type=SelectionType.CHARACTERS) + + if buff.selection_state is not None: + # (`selection_state` should never be `None`, it is created by + # `start_selection`.) + buff.selection_state.enter_shift_mode() + + # Then move the cursor + original_position = buff.cursor_position + unshift_move(event) + if buff.cursor_position == original_position: + # Cursor didn't actually move - so cancel selection + # to avoid having an empty selection + buff.exit_selection() + + @handle("s-left", filter=shift_selection_mode) + @handle("s-right", filter=shift_selection_mode) + @handle("s-up", filter=shift_selection_mode) + @handle("s-down", filter=shift_selection_mode) + @handle("s-home", filter=shift_selection_mode) + @handle("s-end", filter=shift_selection_mode) + @handle("c-s-left", filter=shift_selection_mode) + @handle("c-s-right", filter=shift_selection_mode) + @handle("c-s-home", filter=shift_selection_mode) + @handle("c-s-end", filter=shift_selection_mode) + def _extend_selection(event: E) -> None: + """ + Extend the selection + """ + # Just move the cursor, like shift was not pressed + unshift_move(event) + buff = event.current_buffer + + if buff.selection_state is not None: + if buff.cursor_position == buff.selection_state.original_cursor_position: + # selection is now empty, so cancel selection + buff.exit_selection() + + @handle(Keys.Any, filter=shift_selection_mode) + def _replace_selection(event: E) -> None: + """ + Replace selection by what is typed + """ + event.current_buffer.cut_selection() + get_by_name("self-insert").call(event) + + @handle("enter", filter=shift_selection_mode & is_multiline) + def _newline(event: E) -> None: + """ + A newline replaces the selection + """ + event.current_buffer.cut_selection() + event.current_buffer.newline(copy_margin=not in_paste_mode()) + + @handle("backspace", filter=shift_selection_mode) + def _delete(event: E) -> None: + """ + Delete selection. + """ + event.current_buffer.cut_selection() + + @handle("c-y", filter=shift_selection_mode) + def _yank(event: E) -> None: + """ + In shift selection mode, yanking (pasting) replace the selection. + """ + buff = event.current_buffer + if buff.selection_state: + buff.cut_selection() + get_by_name("yank").call(event) + + # moving the cursor in shift selection mode cancels the selection + @handle("left", filter=shift_selection_mode) + @handle("right", filter=shift_selection_mode) + @handle("up", filter=shift_selection_mode) + @handle("down", filter=shift_selection_mode) + @handle("home", filter=shift_selection_mode) + @handle("end", filter=shift_selection_mode) + @handle("c-left", filter=shift_selection_mode) + @handle("c-right", filter=shift_selection_mode) + @handle("c-home", filter=shift_selection_mode) + @handle("c-end", filter=shift_selection_mode) + def _cancel(event: E) -> None: + """ + Cancel selection. + """ + event.current_buffer.exit_selection() + # we then process the cursor movement + key_press = event.key_sequence[0] + event.key_processor.feed(key_press, first=True) + + return ConditionalKeyBindings(key_bindings, emacs_mode) diff --git a/src/prompt_toolkit/key_binding/bindings/focus.py b/src/prompt_toolkit/key_binding/bindings/focus.py new file mode 100644 index 0000000..24aa3ce --- /dev/null +++ b/src/prompt_toolkit/key_binding/bindings/focus.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from prompt_toolkit.key_binding.key_processor import KeyPressEvent + +__all__ = [ + "focus_next", + "focus_previous", +] + +E = KeyPressEvent + + +def focus_next(event: E) -> None: + """ + Focus the next visible Window. + (Often bound to the `Tab` key.) + """ + event.app.layout.focus_next() + + +def focus_previous(event: E) -> None: + """ + Focus the previous visible Window. + (Often bound to the `BackTab` key.) + """ + event.app.layout.focus_previous() diff --git a/src/prompt_toolkit/key_binding/bindings/mouse.py b/src/prompt_toolkit/key_binding/bindings/mouse.py new file mode 100644 index 0000000..cb426ce --- /dev/null +++ b/src/prompt_toolkit/key_binding/bindings/mouse.py @@ -0,0 +1,348 @@ +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING + +from prompt_toolkit.data_structures import Point +from prompt_toolkit.key_binding.key_processor import KeyPress, KeyPressEvent +from prompt_toolkit.keys import Keys +from prompt_toolkit.mouse_events import ( + MouseButton, + MouseEvent, + MouseEventType, + MouseModifier, +) + +from ..key_bindings import KeyBindings + +if TYPE_CHECKING: + from prompt_toolkit.key_binding.key_bindings import NotImplementedOrNone + +__all__ = [ + "load_mouse_bindings", +] + +E = KeyPressEvent + +# fmt: off +SCROLL_UP = MouseEventType.SCROLL_UP +SCROLL_DOWN = MouseEventType.SCROLL_DOWN +MOUSE_DOWN = MouseEventType.MOUSE_DOWN +MOUSE_MOVE = MouseEventType.MOUSE_MOVE +MOUSE_UP = MouseEventType.MOUSE_UP + +NO_MODIFIER : frozenset[MouseModifier] = frozenset() +SHIFT : frozenset[MouseModifier] = frozenset({MouseModifier.SHIFT}) +ALT : frozenset[MouseModifier] = frozenset({MouseModifier.ALT}) +SHIFT_ALT : frozenset[MouseModifier] = frozenset({MouseModifier.SHIFT, MouseModifier.ALT}) +CONTROL : frozenset[MouseModifier] = frozenset({MouseModifier.CONTROL}) +SHIFT_CONTROL : frozenset[MouseModifier] = frozenset({MouseModifier.SHIFT, MouseModifier.CONTROL}) +ALT_CONTROL : frozenset[MouseModifier] = frozenset({MouseModifier.ALT, MouseModifier.CONTROL}) +SHIFT_ALT_CONTROL: frozenset[MouseModifier] = frozenset({MouseModifier.SHIFT, MouseModifier.ALT, MouseModifier.CONTROL}) +UNKNOWN_MODIFIER : frozenset[MouseModifier] = frozenset() + +LEFT = MouseButton.LEFT +MIDDLE = MouseButton.MIDDLE +RIGHT = MouseButton.RIGHT +NO_BUTTON = MouseButton.NONE +UNKNOWN_BUTTON = MouseButton.UNKNOWN + +xterm_sgr_mouse_events = { + ( 0, "m") : (LEFT, MOUSE_UP, NO_MODIFIER), # left_up 0+ + + =0 + ( 4, "m") : (LEFT, MOUSE_UP, SHIFT), # left_up Shift 0+4+ + =4 + ( 8, "m") : (LEFT, MOUSE_UP, ALT), # left_up Alt 0+ +8+ =8 + (12, "m") : (LEFT, MOUSE_UP, SHIFT_ALT), # left_up Shift Alt 0+4+8+ =12 + (16, "m") : (LEFT, MOUSE_UP, CONTROL), # left_up Control 0+ + +16=16 + (20, "m") : (LEFT, MOUSE_UP, SHIFT_CONTROL), # left_up Shift Control 0+4+ +16=20 + (24, "m") : (LEFT, MOUSE_UP, ALT_CONTROL), # left_up Alt Control 0+ +8+16=24 + (28, "m") : (LEFT, MOUSE_UP, SHIFT_ALT_CONTROL), # left_up Shift Alt Control 0+4+8+16=28 + + ( 1, "m") : (MIDDLE, MOUSE_UP, NO_MODIFIER), # middle_up 1+ + + =1 + ( 5, "m") : (MIDDLE, MOUSE_UP, SHIFT), # middle_up Shift 1+4+ + =5 + ( 9, "m") : (MIDDLE, MOUSE_UP, ALT), # middle_up Alt 1+ +8+ =9 + (13, "m") : (MIDDLE, MOUSE_UP, SHIFT_ALT), # middle_up Shift Alt 1+4+8+ =13 + (17, "m") : (MIDDLE, MOUSE_UP, CONTROL), # middle_up Control 1+ + +16=17 + (21, "m") : (MIDDLE, MOUSE_UP, SHIFT_CONTROL), # middle_up Shift Control 1+4+ +16=21 + (25, "m") : (MIDDLE, MOUSE_UP, ALT_CONTROL), # middle_up Alt Control 1+ +8+16=25 + (29, "m") : (MIDDLE, MOUSE_UP, SHIFT_ALT_CONTROL), # middle_up Shift Alt Control 1+4+8+16=29 + + ( 2, "m") : (RIGHT, MOUSE_UP, NO_MODIFIER), # right_up 2+ + + =2 + ( 6, "m") : (RIGHT, MOUSE_UP, SHIFT), # right_up Shift 2+4+ + =6 + (10, "m") : (RIGHT, MOUSE_UP, ALT), # right_up Alt 2+ +8+ =10 + (14, "m") : (RIGHT, MOUSE_UP, SHIFT_ALT), # right_up Shift Alt 2+4+8+ =14 + (18, "m") : (RIGHT, MOUSE_UP, CONTROL), # right_up Control 2+ + +16=18 + (22, "m") : (RIGHT, MOUSE_UP, SHIFT_CONTROL), # right_up Shift Control 2+4+ +16=22 + (26, "m") : (RIGHT, MOUSE_UP, ALT_CONTROL), # right_up Alt Control 2+ +8+16=26 + (30, "m") : (RIGHT, MOUSE_UP, SHIFT_ALT_CONTROL), # right_up Shift Alt Control 2+4+8+16=30 + + ( 0, "M") : (LEFT, MOUSE_DOWN, NO_MODIFIER), # left_down 0+ + + =0 + ( 4, "M") : (LEFT, MOUSE_DOWN, SHIFT), # left_down Shift 0+4+ + =4 + ( 8, "M") : (LEFT, MOUSE_DOWN, ALT), # left_down Alt 0+ +8+ =8 + (12, "M") : (LEFT, MOUSE_DOWN, SHIFT_ALT), # left_down Shift Alt 0+4+8+ =12 + (16, "M") : (LEFT, MOUSE_DOWN, CONTROL), # left_down Control 0+ + +16=16 + (20, "M") : (LEFT, MOUSE_DOWN, SHIFT_CONTROL), # left_down Shift Control 0+4+ +16=20 + (24, "M") : (LEFT, MOUSE_DOWN, ALT_CONTROL), # left_down Alt Control 0+ +8+16=24 + (28, "M") : (LEFT, MOUSE_DOWN, SHIFT_ALT_CONTROL), # left_down Shift Alt Control 0+4+8+16=28 + + ( 1, "M") : (MIDDLE, MOUSE_DOWN, NO_MODIFIER), # middle_down 1+ + + =1 + ( 5, "M") : (MIDDLE, MOUSE_DOWN, SHIFT), # middle_down Shift 1+4+ + =5 + ( 9, "M") : (MIDDLE, MOUSE_DOWN, ALT), # middle_down Alt 1+ +8+ =9 + (13, "M") : (MIDDLE, MOUSE_DOWN, SHIFT_ALT), # middle_down Shift Alt 1+4+8+ =13 + (17, "M") : (MIDDLE, MOUSE_DOWN, CONTROL), # middle_down Control 1+ + +16=17 + (21, "M") : (MIDDLE, MOUSE_DOWN, SHIFT_CONTROL), # middle_down Shift Control 1+4+ +16=21 + (25, "M") : (MIDDLE, MOUSE_DOWN, ALT_CONTROL), # middle_down Alt Control 1+ +8+16=25 + (29, "M") : (MIDDLE, MOUSE_DOWN, SHIFT_ALT_CONTROL), # middle_down Shift Alt Control 1+4+8+16=29 + + ( 2, "M") : (RIGHT, MOUSE_DOWN, NO_MODIFIER), # right_down 2+ + + =2 + ( 6, "M") : (RIGHT, MOUSE_DOWN, SHIFT), # right_down Shift 2+4+ + =6 + (10, "M") : (RIGHT, MOUSE_DOWN, ALT), # right_down Alt 2+ +8+ =10 + (14, "M") : (RIGHT, MOUSE_DOWN, SHIFT_ALT), # right_down Shift Alt 2+4+8+ =14 + (18, "M") : (RIGHT, MOUSE_DOWN, CONTROL), # right_down Control 2+ + +16=18 + (22, "M") : (RIGHT, MOUSE_DOWN, SHIFT_CONTROL), # right_down Shift Control 2+4+ +16=22 + (26, "M") : (RIGHT, MOUSE_DOWN, ALT_CONTROL), # right_down Alt Control 2+ +8+16=26 + (30, "M") : (RIGHT, MOUSE_DOWN, SHIFT_ALT_CONTROL), # right_down Shift Alt Control 2+4+8+16=30 + + (32, "M") : (LEFT, MOUSE_MOVE, NO_MODIFIER), # left_drag 32+ + + =32 + (36, "M") : (LEFT, MOUSE_MOVE, SHIFT), # left_drag Shift 32+4+ + =36 + (40, "M") : (LEFT, MOUSE_MOVE, ALT), # left_drag Alt 32+ +8+ =40 + (44, "M") : (LEFT, MOUSE_MOVE, SHIFT_ALT), # left_drag Shift Alt 32+4+8+ =44 + (48, "M") : (LEFT, MOUSE_MOVE, CONTROL), # left_drag Control 32+ + +16=48 + (52, "M") : (LEFT, MOUSE_MOVE, SHIFT_CONTROL), # left_drag Shift Control 32+4+ +16=52 + (56, "M") : (LEFT, MOUSE_MOVE, ALT_CONTROL), # left_drag Alt Control 32+ +8+16=56 + (60, "M") : (LEFT, MOUSE_MOVE, SHIFT_ALT_CONTROL), # left_drag Shift Alt Control 32+4+8+16=60 + + (33, "M") : (MIDDLE, MOUSE_MOVE, NO_MODIFIER), # middle_drag 33+ + + =33 + (37, "M") : (MIDDLE, MOUSE_MOVE, SHIFT), # middle_drag Shift 33+4+ + =37 + (41, "M") : (MIDDLE, MOUSE_MOVE, ALT), # middle_drag Alt 33+ +8+ =41 + (45, "M") : (MIDDLE, MOUSE_MOVE, SHIFT_ALT), # middle_drag Shift Alt 33+4+8+ =45 + (49, "M") : (MIDDLE, MOUSE_MOVE, CONTROL), # middle_drag Control 33+ + +16=49 + (53, "M") : (MIDDLE, MOUSE_MOVE, SHIFT_CONTROL), # middle_drag Shift Control 33+4+ +16=53 + (57, "M") : (MIDDLE, MOUSE_MOVE, ALT_CONTROL), # middle_drag Alt Control 33+ +8+16=57 + (61, "M") : (MIDDLE, MOUSE_MOVE, SHIFT_ALT_CONTROL), # middle_drag Shift Alt Control 33+4+8+16=61 + + (34, "M") : (RIGHT, MOUSE_MOVE, NO_MODIFIER), # right_drag 34+ + + =34 + (38, "M") : (RIGHT, MOUSE_MOVE, SHIFT), # right_drag Shift 34+4+ + =38 + (42, "M") : (RIGHT, MOUSE_MOVE, ALT), # right_drag Alt 34+ +8+ =42 + (46, "M") : (RIGHT, MOUSE_MOVE, SHIFT_ALT), # right_drag Shift Alt 34+4+8+ =46 + (50, "M") : (RIGHT, MOUSE_MOVE, CONTROL), # right_drag Control 34+ + +16=50 + (54, "M") : (RIGHT, MOUSE_MOVE, SHIFT_CONTROL), # right_drag Shift Control 34+4+ +16=54 + (58, "M") : (RIGHT, MOUSE_MOVE, ALT_CONTROL), # right_drag Alt Control 34+ +8+16=58 + (62, "M") : (RIGHT, MOUSE_MOVE, SHIFT_ALT_CONTROL), # right_drag Shift Alt Control 34+4+8+16=62 + + (35, "M") : (NO_BUTTON, MOUSE_MOVE, NO_MODIFIER), # none_drag 35+ + + =35 + (39, "M") : (NO_BUTTON, MOUSE_MOVE, SHIFT), # none_drag Shift 35+4+ + =39 + (43, "M") : (NO_BUTTON, MOUSE_MOVE, ALT), # none_drag Alt 35+ +8+ =43 + (47, "M") : (NO_BUTTON, MOUSE_MOVE, SHIFT_ALT), # none_drag Shift Alt 35+4+8+ =47 + (51, "M") : (NO_BUTTON, MOUSE_MOVE, CONTROL), # none_drag Control 35+ + +16=51 + (55, "M") : (NO_BUTTON, MOUSE_MOVE, SHIFT_CONTROL), # none_drag Shift Control 35+4+ +16=55 + (59, "M") : (NO_BUTTON, MOUSE_MOVE, ALT_CONTROL), # none_drag Alt Control 35+ +8+16=59 + (63, "M") : (NO_BUTTON, MOUSE_MOVE, SHIFT_ALT_CONTROL), # none_drag Shift Alt Control 35+4+8+16=63 + + (64, "M") : (NO_BUTTON, SCROLL_UP, NO_MODIFIER), # scroll_up 64+ + + =64 + (68, "M") : (NO_BUTTON, SCROLL_UP, SHIFT), # scroll_up Shift 64+4+ + =68 + (72, "M") : (NO_BUTTON, SCROLL_UP, ALT), # scroll_up Alt 64+ +8+ =72 + (76, "M") : (NO_BUTTON, SCROLL_UP, SHIFT_ALT), # scroll_up Shift Alt 64+4+8+ =76 + (80, "M") : (NO_BUTTON, SCROLL_UP, CONTROL), # scroll_up Control 64+ + +16=80 + (84, "M") : (NO_BUTTON, SCROLL_UP, SHIFT_CONTROL), # scroll_up Shift Control 64+4+ +16=84 + (88, "M") : (NO_BUTTON, SCROLL_UP, ALT_CONTROL), # scroll_up Alt Control 64+ +8+16=88 + (92, "M") : (NO_BUTTON, SCROLL_UP, SHIFT_ALT_CONTROL), # scroll_up Shift Alt Control 64+4+8+16=92 + + (65, "M") : (NO_BUTTON, SCROLL_DOWN, NO_MODIFIER), # scroll_down 64+ + + =65 + (69, "M") : (NO_BUTTON, SCROLL_DOWN, SHIFT), # scroll_down Shift 64+4+ + =69 + (73, "M") : (NO_BUTTON, SCROLL_DOWN, ALT), # scroll_down Alt 64+ +8+ =73 + (77, "M") : (NO_BUTTON, SCROLL_DOWN, SHIFT_ALT), # scroll_down Shift Alt 64+4+8+ =77 + (81, "M") : (NO_BUTTON, SCROLL_DOWN, CONTROL), # scroll_down Control 64+ + +16=81 + (85, "M") : (NO_BUTTON, SCROLL_DOWN, SHIFT_CONTROL), # scroll_down Shift Control 64+4+ +16=85 + (89, "M") : (NO_BUTTON, SCROLL_DOWN, ALT_CONTROL), # scroll_down Alt Control 64+ +8+16=89 + (93, "M") : (NO_BUTTON, SCROLL_DOWN, SHIFT_ALT_CONTROL), # scroll_down Shift Alt Control 64+4+8+16=93 +} + +typical_mouse_events = { + 32: (LEFT , MOUSE_DOWN , UNKNOWN_MODIFIER), + 33: (MIDDLE , MOUSE_DOWN , UNKNOWN_MODIFIER), + 34: (RIGHT , MOUSE_DOWN , UNKNOWN_MODIFIER), + 35: (UNKNOWN_BUTTON , MOUSE_UP , UNKNOWN_MODIFIER), + + 64: (LEFT , MOUSE_MOVE , UNKNOWN_MODIFIER), + 65: (MIDDLE , MOUSE_MOVE , UNKNOWN_MODIFIER), + 66: (RIGHT , MOUSE_MOVE , UNKNOWN_MODIFIER), + 67: (NO_BUTTON , MOUSE_MOVE , UNKNOWN_MODIFIER), + + 96: (NO_BUTTON , SCROLL_UP , UNKNOWN_MODIFIER), + 97: (NO_BUTTON , SCROLL_DOWN, UNKNOWN_MODIFIER), +} + +urxvt_mouse_events={ + 32: (UNKNOWN_BUTTON, MOUSE_DOWN , UNKNOWN_MODIFIER), + 35: (UNKNOWN_BUTTON, MOUSE_UP , UNKNOWN_MODIFIER), + 96: (NO_BUTTON , SCROLL_UP , UNKNOWN_MODIFIER), + 97: (NO_BUTTON , SCROLL_DOWN, UNKNOWN_MODIFIER), +} +# fmt:on + + +def load_mouse_bindings() -> KeyBindings: + """ + Key bindings, required for mouse support. + (Mouse events enter through the key binding system.) + """ + key_bindings = KeyBindings() + + @key_bindings.add(Keys.Vt100MouseEvent) + def _(event: E) -> NotImplementedOrNone: + """ + Handling of incoming mouse event. + """ + # TypicaL: "eSC[MaB*" + # Urxvt: "Esc[96;14;13M" + # Xterm SGR: "Esc[<64;85;12M" + + # Parse incoming packet. + if event.data[2] == "M": + # Typical. + mouse_event, x, y = map(ord, event.data[3:]) + + # TODO: Is it possible to add modifiers here? + mouse_button, mouse_event_type, mouse_modifiers = typical_mouse_events[ + mouse_event + ] + + # Handle situations where `PosixStdinReader` used surrogateescapes. + if x >= 0xDC00: + x -= 0xDC00 + if y >= 0xDC00: + y -= 0xDC00 + + x -= 32 + y -= 32 + else: + # Urxvt and Xterm SGR. + # When the '<' is not present, we are not using the Xterm SGR mode, + # but Urxvt instead. + data = event.data[2:] + if data[:1] == "<": + sgr = True + data = data[1:] + else: + sgr = False + + # Extract coordinates. + mouse_event, x, y = map(int, data[:-1].split(";")) + m = data[-1] + + # Parse event type. + if sgr: + try: + ( + mouse_button, + mouse_event_type, + mouse_modifiers, + ) = xterm_sgr_mouse_events[mouse_event, m] + except KeyError: + return NotImplemented + + else: + # Some other terminals, like urxvt, Hyper terminal, ... + ( + mouse_button, + mouse_event_type, + mouse_modifiers, + ) = urxvt_mouse_events.get( + mouse_event, (UNKNOWN_BUTTON, MOUSE_MOVE, UNKNOWN_MODIFIER) + ) + + x -= 1 + y -= 1 + + # Only handle mouse events when we know the window height. + if event.app.renderer.height_is_known and mouse_event_type is not None: + # Take region above the layout into account. The reported + # coordinates are absolute to the visible part of the terminal. + from prompt_toolkit.renderer import HeightIsUnknownError + + try: + y -= event.app.renderer.rows_above_layout + except HeightIsUnknownError: + return NotImplemented + + # Call the mouse handler from the renderer. + + # Note: This can return `NotImplemented` if no mouse handler was + # found for this position, or if no repainting needs to + # happen. this way, we avoid excessive repaints during mouse + # movements. + handler = event.app.renderer.mouse_handlers.mouse_handlers[y][x] + return handler( + MouseEvent( + position=Point(x=x, y=y), + event_type=mouse_event_type, + button=mouse_button, + modifiers=mouse_modifiers, + ) + ) + + return NotImplemented + + @key_bindings.add(Keys.ScrollUp) + def _scroll_up(event: E) -> None: + """ + Scroll up event without cursor position. + """ + # We don't receive a cursor position, so we don't know which window to + # scroll. Just send an 'up' key press instead. + event.key_processor.feed(KeyPress(Keys.Up), first=True) + + @key_bindings.add(Keys.ScrollDown) + def _scroll_down(event: E) -> None: + """ + Scroll down event without cursor position. + """ + event.key_processor.feed(KeyPress(Keys.Down), first=True) + + @key_bindings.add(Keys.WindowsMouseEvent) + def _mouse(event: E) -> NotImplementedOrNone: + """ + Handling of mouse events for Windows. + """ + # This key binding should only exist for Windows. + if sys.platform == "win32": + # Parse data. + pieces = event.data.split(";") + + button = MouseButton(pieces[0]) + event_type = MouseEventType(pieces[1]) + x = int(pieces[2]) + y = int(pieces[3]) + + # Make coordinates absolute to the visible part of the terminal. + output = event.app.renderer.output + + from prompt_toolkit.output.win32 import Win32Output + from prompt_toolkit.output.windows10 import Windows10_Output + + if isinstance(output, (Win32Output, Windows10_Output)): + screen_buffer_info = output.get_win32_screen_buffer_info() + rows_above_cursor = ( + screen_buffer_info.dwCursorPosition.Y + - event.app.renderer._cursor_pos.y + ) + y -= rows_above_cursor + + # Call the mouse event handler. + # (Can return `NotImplemented`.) + handler = event.app.renderer.mouse_handlers.mouse_handlers[y][x] + + return handler( + MouseEvent( + position=Point(x=x, y=y), + event_type=event_type, + button=button, + modifiers=UNKNOWN_MODIFIER, + ) + ) + + # No mouse handler found. Return `NotImplemented` so that we don't + # invalidate the UI. + return NotImplemented + + return key_bindings diff --git a/src/prompt_toolkit/key_binding/bindings/named_commands.py b/src/prompt_toolkit/key_binding/bindings/named_commands.py new file mode 100644 index 0000000..d836116 --- /dev/null +++ b/src/prompt_toolkit/key_binding/bindings/named_commands.py @@ -0,0 +1,690 @@ +""" +Key bindings which are also known by GNU Readline by the given names. + +See: http://www.delorie.com/gnu/docs/readline/rlman_13.html +""" +from __future__ import annotations + +from typing import Callable, TypeVar, Union, cast + +from prompt_toolkit.document import Document +from prompt_toolkit.enums import EditingMode +from prompt_toolkit.key_binding.key_bindings import Binding, key_binding +from prompt_toolkit.key_binding.key_processor import KeyPress, KeyPressEvent +from prompt_toolkit.keys import Keys +from prompt_toolkit.layout.controls import BufferControl +from prompt_toolkit.search import SearchDirection +from prompt_toolkit.selection import PasteMode + +from .completion import display_completions_like_readline, generate_completions + +__all__ = [ + "get_by_name", +] + + +# Typing. +_Handler = Callable[[KeyPressEvent], None] +_HandlerOrBinding = Union[_Handler, Binding] +_T = TypeVar("_T", bound=_HandlerOrBinding) +E = KeyPressEvent + + +# Registry that maps the Readline command names to their handlers. +_readline_commands: dict[str, Binding] = {} + + +def register(name: str) -> Callable[[_T], _T]: + """ + Store handler in the `_readline_commands` dictionary. + """ + + def decorator(handler: _T) -> _T: + "`handler` is a callable or Binding." + if isinstance(handler, Binding): + _readline_commands[name] = handler + else: + _readline_commands[name] = key_binding()(cast(_Handler, handler)) + + return handler + + return decorator + + +def get_by_name(name: str) -> Binding: + """ + Return the handler for the (Readline) command with the given name. + """ + try: + return _readline_commands[name] + except KeyError as e: + raise KeyError("Unknown Readline command: %r" % name) from e + + +# +# Commands for moving +# See: http://www.delorie.com/gnu/docs/readline/rlman_14.html +# + + +@register("beginning-of-buffer") +def beginning_of_buffer(event: E) -> None: + """ + Move to the start of the buffer. + """ + buff = event.current_buffer + buff.cursor_position = 0 + + +@register("end-of-buffer") +def end_of_buffer(event: E) -> None: + """ + Move to the end of the buffer. + """ + buff = event.current_buffer + buff.cursor_position = len(buff.text) + + +@register("beginning-of-line") +def beginning_of_line(event: E) -> None: + """ + Move to the start of the current line. + """ + buff = event.current_buffer + buff.cursor_position += buff.document.get_start_of_line_position( + after_whitespace=False + ) + + +@register("end-of-line") +def end_of_line(event: E) -> None: + """ + Move to the end of the line. + """ + buff = event.current_buffer + buff.cursor_position += buff.document.get_end_of_line_position() + + +@register("forward-char") +def forward_char(event: E) -> None: + """ + Move forward a character. + """ + buff = event.current_buffer + buff.cursor_position += buff.document.get_cursor_right_position(count=event.arg) + + +@register("backward-char") +def backward_char(event: E) -> None: + "Move back a character." + buff = event.current_buffer + buff.cursor_position += buff.document.get_cursor_left_position(count=event.arg) + + +@register("forward-word") +def forward_word(event: E) -> None: + """ + Move forward to the end of the next word. Words are composed of letters and + digits. + """ + buff = event.current_buffer + pos = buff.document.find_next_word_ending(count=event.arg) + + if pos: + buff.cursor_position += pos + + +@register("backward-word") +def backward_word(event: E) -> None: + """ + Move back to the start of the current or previous word. Words are composed + of letters and digits. + """ + buff = event.current_buffer + pos = buff.document.find_previous_word_beginning(count=event.arg) + + if pos: + buff.cursor_position += pos + + +@register("clear-screen") +def clear_screen(event: E) -> None: + """ + Clear the screen and redraw everything at the top of the screen. + """ + event.app.renderer.clear() + + +@register("redraw-current-line") +def redraw_current_line(event: E) -> None: + """ + Refresh the current line. + (Readline defines this command, but prompt-toolkit doesn't have it.) + """ + pass + + +# +# Commands for manipulating the history. +# See: http://www.delorie.com/gnu/docs/readline/rlman_15.html +# + + +@register("accept-line") +def accept_line(event: E) -> None: + """ + Accept the line regardless of where the cursor is. + """ + event.current_buffer.validate_and_handle() + + +@register("previous-history") +def previous_history(event: E) -> None: + """ + Move `back` through the history list, fetching the previous command. + """ + event.current_buffer.history_backward(count=event.arg) + + +@register("next-history") +def next_history(event: E) -> None: + """ + Move `forward` through the history list, fetching the next command. + """ + event.current_buffer.history_forward(count=event.arg) + + +@register("beginning-of-history") +def beginning_of_history(event: E) -> None: + """ + Move to the first line in the history. + """ + event.current_buffer.go_to_history(0) + + +@register("end-of-history") +def end_of_history(event: E) -> None: + """ + Move to the end of the input history, i.e., the line currently being entered. + """ + event.current_buffer.history_forward(count=10**100) + buff = event.current_buffer + buff.go_to_history(len(buff._working_lines) - 1) + + +@register("reverse-search-history") +def reverse_search_history(event: E) -> None: + """ + Search backward starting at the current line and moving `up` through + the history as necessary. This is an incremental search. + """ + control = event.app.layout.current_control + + if isinstance(control, BufferControl) and control.search_buffer_control: + event.app.current_search_state.direction = SearchDirection.BACKWARD + event.app.layout.current_control = control.search_buffer_control + + +# +# Commands for changing text +# + + +@register("end-of-file") +def end_of_file(event: E) -> None: + """ + Exit. + """ + event.app.exit() + + +@register("delete-char") +def delete_char(event: E) -> None: + """ + Delete character before the cursor. + """ + deleted = event.current_buffer.delete(count=event.arg) + if not deleted: + event.app.output.bell() + + +@register("backward-delete-char") +def backward_delete_char(event: E) -> None: + """ + Delete the character behind the cursor. + """ + if event.arg < 0: + # When a negative argument has been given, this should delete in front + # of the cursor. + deleted = event.current_buffer.delete(count=-event.arg) + else: + deleted = event.current_buffer.delete_before_cursor(count=event.arg) + + if not deleted: + event.app.output.bell() + + +@register("self-insert") +def self_insert(event: E) -> None: + """ + Insert yourself. + """ + event.current_buffer.insert_text(event.data * event.arg) + + +@register("transpose-chars") +def transpose_chars(event: E) -> None: + """ + Emulate Emacs transpose-char behavior: at the beginning of the buffer, + do nothing. At the end of a line or buffer, swap the characters before + the cursor. Otherwise, move the cursor right, and then swap the + characters before the cursor. + """ + b = event.current_buffer + p = b.cursor_position + if p == 0: + return + elif p == len(b.text) or b.text[p] == "\n": + b.swap_characters_before_cursor() + else: + b.cursor_position += b.document.get_cursor_right_position() + b.swap_characters_before_cursor() + + +@register("uppercase-word") +def uppercase_word(event: E) -> None: + """ + Uppercase the current (or following) word. + """ + buff = event.current_buffer + + for i in range(event.arg): + pos = buff.document.find_next_word_ending() + words = buff.document.text_after_cursor[:pos] + buff.insert_text(words.upper(), overwrite=True) + + +@register("downcase-word") +def downcase_word(event: E) -> None: + """ + Lowercase the current (or following) word. + """ + buff = event.current_buffer + + for i in range(event.arg): # XXX: not DRY: see meta_c and meta_u!! + pos = buff.document.find_next_word_ending() + words = buff.document.text_after_cursor[:pos] + buff.insert_text(words.lower(), overwrite=True) + + +@register("capitalize-word") +def capitalize_word(event: E) -> None: + """ + Capitalize the current (or following) word. + """ + buff = event.current_buffer + + for i in range(event.arg): + pos = buff.document.find_next_word_ending() + words = buff.document.text_after_cursor[:pos] + buff.insert_text(words.title(), overwrite=True) + + +@register("quoted-insert") +def quoted_insert(event: E) -> None: + """ + Add the next character typed to the line verbatim. This is how to insert + key sequences like C-q, for example. + """ + event.app.quoted_insert = True + + +# +# Killing and yanking. +# + + +@register("kill-line") +def kill_line(event: E) -> None: + """ + Kill the text from the cursor to the end of the line. + + If we are at the end of the line, this should remove the newline. + (That way, it is possible to delete multiple lines by executing this + command multiple times.) + """ + buff = event.current_buffer + if event.arg < 0: + deleted = buff.delete_before_cursor( + count=-buff.document.get_start_of_line_position() + ) + else: + if buff.document.current_char == "\n": + deleted = buff.delete(1) + else: + deleted = buff.delete(count=buff.document.get_end_of_line_position()) + event.app.clipboard.set_text(deleted) + + +@register("kill-word") +def kill_word(event: E) -> None: + """ + Kill from point to the end of the current word, or if between words, to the + end of the next word. Word boundaries are the same as forward-word. + """ + buff = event.current_buffer + pos = buff.document.find_next_word_ending(count=event.arg) + + if pos: + deleted = buff.delete(count=pos) + + if event.is_repeat: + deleted = event.app.clipboard.get_data().text + deleted + + event.app.clipboard.set_text(deleted) + + +@register("unix-word-rubout") +def unix_word_rubout(event: E, WORD: bool = True) -> None: + """ + Kill the word behind point, using whitespace as a word boundary. + Usually bound to ControlW. + """ + buff = event.current_buffer + pos = buff.document.find_start_of_previous_word(count=event.arg, WORD=WORD) + + if pos is None: + # Nothing found? delete until the start of the document. (The + # input starts with whitespace and no words were found before the + # cursor.) + pos = -buff.cursor_position + + if pos: + deleted = buff.delete_before_cursor(count=-pos) + + # If the previous key press was also Control-W, concatenate deleted + # text. + if event.is_repeat: + deleted += event.app.clipboard.get_data().text + + event.app.clipboard.set_text(deleted) + else: + # Nothing to delete. Bell. + event.app.output.bell() + + +@register("backward-kill-word") +def backward_kill_word(event: E) -> None: + """ + Kills the word before point, using "not a letter nor a digit" as a word boundary. + Usually bound to M-Del or M-Backspace. + """ + unix_word_rubout(event, WORD=False) + + +@register("delete-horizontal-space") +def delete_horizontal_space(event: E) -> None: + """ + Delete all spaces and tabs around point. + """ + buff = event.current_buffer + text_before_cursor = buff.document.text_before_cursor + text_after_cursor = buff.document.text_after_cursor + + delete_before = len(text_before_cursor) - len(text_before_cursor.rstrip("\t ")) + delete_after = len(text_after_cursor) - len(text_after_cursor.lstrip("\t ")) + + buff.delete_before_cursor(count=delete_before) + buff.delete(count=delete_after) + + +@register("unix-line-discard") +def unix_line_discard(event: E) -> None: + """ + Kill backward from the cursor to the beginning of the current line. + """ + buff = event.current_buffer + + if buff.document.cursor_position_col == 0 and buff.document.cursor_position > 0: + buff.delete_before_cursor(count=1) + else: + deleted = buff.delete_before_cursor( + count=-buff.document.get_start_of_line_position() + ) + event.app.clipboard.set_text(deleted) + + +@register("yank") +def yank(event: E) -> None: + """ + Paste before cursor. + """ + event.current_buffer.paste_clipboard_data( + event.app.clipboard.get_data(), count=event.arg, paste_mode=PasteMode.EMACS + ) + + +@register("yank-nth-arg") +def yank_nth_arg(event: E) -> None: + """ + Insert the first argument of the previous command. With an argument, insert + the nth word from the previous command (start counting at 0). + """ + n = event.arg if event.arg_present else None + event.current_buffer.yank_nth_arg(n) + + +@register("yank-last-arg") +def yank_last_arg(event: E) -> None: + """ + Like `yank_nth_arg`, but if no argument has been given, yank the last word + of each line. + """ + n = event.arg if event.arg_present else None + event.current_buffer.yank_last_arg(n) + + +@register("yank-pop") +def yank_pop(event: E) -> None: + """ + Rotate the kill ring, and yank the new top. Only works following yank or + yank-pop. + """ + buff = event.current_buffer + doc_before_paste = buff.document_before_paste + clipboard = event.app.clipboard + + if doc_before_paste is not None: + buff.document = doc_before_paste + clipboard.rotate() + buff.paste_clipboard_data(clipboard.get_data(), paste_mode=PasteMode.EMACS) + + +# +# Completion. +# + + +@register("complete") +def complete(event: E) -> None: + """ + Attempt to perform completion. + """ + display_completions_like_readline(event) + + +@register("menu-complete") +def menu_complete(event: E) -> None: + """ + Generate completions, or go to the next completion. (This is the default + way of completing input in prompt_toolkit.) + """ + generate_completions(event) + + +@register("menu-complete-backward") +def menu_complete_backward(event: E) -> None: + """ + Move backward through the list of possible completions. + """ + event.current_buffer.complete_previous() + + +# +# Keyboard macros. +# + + +@register("start-kbd-macro") +def start_kbd_macro(event: E) -> None: + """ + Begin saving the characters typed into the current keyboard macro. + """ + event.app.emacs_state.start_macro() + + +@register("end-kbd-macro") +def end_kbd_macro(event: E) -> None: + """ + Stop saving the characters typed into the current keyboard macro and save + the definition. + """ + event.app.emacs_state.end_macro() + + +@register("call-last-kbd-macro") +@key_binding(record_in_macro=False) +def call_last_kbd_macro(event: E) -> None: + """ + Re-execute the last keyboard macro defined, by making the characters in the + macro appear as if typed at the keyboard. + + Notice that we pass `record_in_macro=False`. This ensures that the 'c-x e' + key sequence doesn't appear in the recording itself. This function inserts + the body of the called macro back into the KeyProcessor, so these keys will + be added later on to the macro of their handlers have `record_in_macro=True`. + """ + # Insert the macro. + macro = event.app.emacs_state.macro + + if macro: + event.app.key_processor.feed_multiple(macro, first=True) + + +@register("print-last-kbd-macro") +def print_last_kbd_macro(event: E) -> None: + """ + Print the last keyboard macro. + """ + + # TODO: Make the format suitable for the inputrc file. + def print_macro() -> None: + macro = event.app.emacs_state.macro + if macro: + for k in macro: + print(k) + + from prompt_toolkit.application.run_in_terminal import run_in_terminal + + run_in_terminal(print_macro) + + +# +# Miscellaneous Commands. +# + + +@register("undo") +def undo(event: E) -> None: + """ + Incremental undo. + """ + event.current_buffer.undo() + + +@register("insert-comment") +def insert_comment(event: E) -> None: + """ + Without numeric argument, comment all lines. + With numeric argument, uncomment all lines. + In any case accept the input. + """ + buff = event.current_buffer + + # Transform all lines. + if event.arg != 1: + + def change(line: str) -> str: + return line[1:] if line.startswith("#") else line + + else: + + def change(line: str) -> str: + return "#" + line + + buff.document = Document( + text="\n".join(map(change, buff.text.splitlines())), cursor_position=0 + ) + + # Accept input. + buff.validate_and_handle() + + +@register("vi-editing-mode") +def vi_editing_mode(event: E) -> None: + """ + Switch to Vi editing mode. + """ + event.app.editing_mode = EditingMode.VI + + +@register("emacs-editing-mode") +def emacs_editing_mode(event: E) -> None: + """ + Switch to Emacs editing mode. + """ + event.app.editing_mode = EditingMode.EMACS + + +@register("prefix-meta") +def prefix_meta(event: E) -> None: + """ + Metafy the next character typed. This is for keyboards without a meta key. + + Sometimes people also want to bind other keys to Meta, e.g. 'jj':: + + key_bindings.add_key_binding('j', 'j', filter=ViInsertMode())(prefix_meta) + """ + # ('first' should be true, because we want to insert it at the current + # position in the queue.) + event.app.key_processor.feed(KeyPress(Keys.Escape), first=True) + + +@register("operate-and-get-next") +def operate_and_get_next(event: E) -> None: + """ + Accept the current line for execution and fetch the next line relative to + the current line from the history for editing. + """ + buff = event.current_buffer + new_index = buff.working_index + 1 + + # Accept the current input. (This will also redraw the interface in the + # 'done' state.) + buff.validate_and_handle() + + # Set the new index at the start of the next run. + def set_working_index() -> None: + if new_index < len(buff._working_lines): + buff.working_index = new_index + + event.app.pre_run_callables.append(set_working_index) + + +@register("edit-and-execute-command") +def edit_and_execute(event: E) -> None: + """ + Invoke an editor on the current command line, and accept the result. + """ + buff = event.current_buffer + buff.open_in_editor(validate_and_handle=True) diff --git a/src/prompt_toolkit/key_binding/bindings/open_in_editor.py b/src/prompt_toolkit/key_binding/bindings/open_in_editor.py new file mode 100644 index 0000000..d156424 --- /dev/null +++ b/src/prompt_toolkit/key_binding/bindings/open_in_editor.py @@ -0,0 +1,51 @@ +""" +Open in editor key bindings. +""" +from __future__ import annotations + +from prompt_toolkit.filters import emacs_mode, has_selection, vi_navigation_mode + +from ..key_bindings import KeyBindings, KeyBindingsBase, merge_key_bindings +from .named_commands import get_by_name + +__all__ = [ + "load_open_in_editor_bindings", + "load_emacs_open_in_editor_bindings", + "load_vi_open_in_editor_bindings", +] + + +def load_open_in_editor_bindings() -> KeyBindingsBase: + """ + Load both the Vi and emacs key bindings for handling edit-and-execute-command. + """ + return merge_key_bindings( + [ + load_emacs_open_in_editor_bindings(), + load_vi_open_in_editor_bindings(), + ] + ) + + +def load_emacs_open_in_editor_bindings() -> KeyBindings: + """ + Pressing C-X C-E will open the buffer in an external editor. + """ + key_bindings = KeyBindings() + + key_bindings.add("c-x", "c-e", filter=emacs_mode & ~has_selection)( + get_by_name("edit-and-execute-command") + ) + + return key_bindings + + +def load_vi_open_in_editor_bindings() -> KeyBindings: + """ + Pressing 'v' in navigation mode will open the buffer in an external editor. + """ + key_bindings = KeyBindings() + key_bindings.add("v", filter=vi_navigation_mode)( + get_by_name("edit-and-execute-command") + ) + return key_bindings diff --git a/src/prompt_toolkit/key_binding/bindings/page_navigation.py b/src/prompt_toolkit/key_binding/bindings/page_navigation.py new file mode 100644 index 0000000..3918e14 --- /dev/null +++ b/src/prompt_toolkit/key_binding/bindings/page_navigation.py @@ -0,0 +1,84 @@ +""" +Key bindings for extra page navigation: bindings for up/down scrolling through +long pages, like in Emacs or Vi. +""" +from __future__ import annotations + +from prompt_toolkit.filters import buffer_has_focus, emacs_mode, vi_mode +from prompt_toolkit.key_binding.key_bindings import ( + ConditionalKeyBindings, + KeyBindings, + KeyBindingsBase, + merge_key_bindings, +) + +from .scroll import ( + scroll_backward, + scroll_forward, + scroll_half_page_down, + scroll_half_page_up, + scroll_one_line_down, + scroll_one_line_up, + scroll_page_down, + scroll_page_up, +) + +__all__ = [ + "load_page_navigation_bindings", + "load_emacs_page_navigation_bindings", + "load_vi_page_navigation_bindings", +] + + +def load_page_navigation_bindings() -> KeyBindingsBase: + """ + Load both the Vi and Emacs bindings for page navigation. + """ + # Only enable when a `Buffer` is focused, otherwise, we would catch keys + # when another widget is focused (like for instance `c-d` in a + # ptterm.Terminal). + return ConditionalKeyBindings( + merge_key_bindings( + [ + load_emacs_page_navigation_bindings(), + load_vi_page_navigation_bindings(), + ] + ), + buffer_has_focus, + ) + + +def load_emacs_page_navigation_bindings() -> KeyBindingsBase: + """ + Key bindings, for scrolling up and down through pages. + This are separate bindings, because GNU readline doesn't have them. + """ + key_bindings = KeyBindings() + handle = key_bindings.add + + handle("c-v")(scroll_page_down) + handle("pagedown")(scroll_page_down) + handle("escape", "v")(scroll_page_up) + handle("pageup")(scroll_page_up) + + return ConditionalKeyBindings(key_bindings, emacs_mode) + + +def load_vi_page_navigation_bindings() -> KeyBindingsBase: + """ + Key bindings, for scrolling up and down through pages. + This are separate bindings, because GNU readline doesn't have them. + """ + key_bindings = KeyBindings() + handle = key_bindings.add + + handle("c-f")(scroll_forward) + handle("c-b")(scroll_backward) + handle("c-d")(scroll_half_page_down) + handle("c-u")(scroll_half_page_up) + handle("c-e")(scroll_one_line_down) + handle("c-y")(scroll_one_line_up) + handle("pagedown")(scroll_page_down) + handle("pageup")(scroll_page_up) + + return ConditionalKeyBindings(key_bindings, vi_mode) diff --git a/src/prompt_toolkit/key_binding/bindings/scroll.py b/src/prompt_toolkit/key_binding/bindings/scroll.py new file mode 100644 index 0000000..83a4be1 --- /dev/null +++ b/src/prompt_toolkit/key_binding/bindings/scroll.py @@ -0,0 +1,189 @@ +""" +Key bindings, for scrolling up and down through pages. + +This are separate bindings, because GNU readline doesn't have them, but +they are very useful for navigating through long multiline buffers, like in +Vi, Emacs, etc... +""" +from __future__ import annotations + +from prompt_toolkit.key_binding.key_processor import KeyPressEvent + +__all__ = [ + "scroll_forward", + "scroll_backward", + "scroll_half_page_up", + "scroll_half_page_down", + "scroll_one_line_up", + "scroll_one_line_down", +] + +E = KeyPressEvent + + +def scroll_forward(event: E, half: bool = False) -> None: + """ + Scroll window down. + """ + w = event.app.layout.current_window + b = event.app.current_buffer + + if w and w.render_info: + info = w.render_info + ui_content = info.ui_content + + # Height to scroll. + scroll_height = info.window_height + if half: + scroll_height //= 2 + + # Calculate how many lines is equivalent to that vertical space. + y = b.document.cursor_position_row + 1 + height = 0 + while y < ui_content.line_count: + line_height = info.get_height_for_line(y) + + if height + line_height < scroll_height: + height += line_height + y += 1 + else: + break + + b.cursor_position = b.document.translate_row_col_to_index(y, 0) + + +def scroll_backward(event: E, half: bool = False) -> None: + """ + Scroll window up. + """ + w = event.app.layout.current_window + b = event.app.current_buffer + + if w and w.render_info: + info = w.render_info + + # Height to scroll. + scroll_height = info.window_height + if half: + scroll_height //= 2 + + # Calculate how many lines is equivalent to that vertical space. + y = max(0, b.document.cursor_position_row - 1) + height = 0 + while y > 0: + line_height = info.get_height_for_line(y) + + if height + line_height < scroll_height: + height += line_height + y -= 1 + else: + break + + b.cursor_position = b.document.translate_row_col_to_index(y, 0) + + +def scroll_half_page_down(event: E) -> None: + """ + Same as ControlF, but only scroll half a page. + """ + scroll_forward(event, half=True) + + +def scroll_half_page_up(event: E) -> None: + """ + Same as ControlB, but only scroll half a page. + """ + scroll_backward(event, half=True) + + +def scroll_one_line_down(event: E) -> None: + """ + scroll_offset += 1 + """ + w = event.app.layout.current_window + b = event.app.current_buffer + + if w: + # When the cursor is at the top, move to the next line. (Otherwise, only scroll.) + if w.render_info: + info = w.render_info + + if w.vertical_scroll < info.content_height - info.window_height: + if info.cursor_position.y <= info.configured_scroll_offsets.top: + b.cursor_position += b.document.get_cursor_down_position() + + w.vertical_scroll += 1 + + +def scroll_one_line_up(event: E) -> None: + """ + scroll_offset -= 1 + """ + w = event.app.layout.current_window + b = event.app.current_buffer + + if w: + # When the cursor is at the bottom, move to the previous line. (Otherwise, only scroll.) + if w.render_info: + info = w.render_info + + if w.vertical_scroll > 0: + first_line_height = info.get_height_for_line(info.first_visible_line()) + + cursor_up = info.cursor_position.y - ( + info.window_height + - 1 + - first_line_height + - info.configured_scroll_offsets.bottom + ) + + # Move cursor up, as many steps as the height of the first line. + # TODO: not entirely correct yet, in case of line wrapping and many long lines. + for _ in range(max(0, cursor_up)): + b.cursor_position += b.document.get_cursor_up_position() + + # Scroll window + w.vertical_scroll -= 1 + + +def scroll_page_down(event: E) -> None: + """ + Scroll page down. (Prefer the cursor at the top of the page, after scrolling.) + """ + w = event.app.layout.current_window + b = event.app.current_buffer + + if w and w.render_info: + # Scroll down one page. + line_index = max(w.render_info.last_visible_line(), w.vertical_scroll + 1) + w.vertical_scroll = line_index + + b.cursor_position = b.document.translate_row_col_to_index(line_index, 0) + b.cursor_position += b.document.get_start_of_line_position( + after_whitespace=True + ) + + +def scroll_page_up(event: E) -> None: + """ + Scroll page up. (Prefer the cursor at the bottom of the page, after scrolling.) + """ + w = event.app.layout.current_window + b = event.app.current_buffer + + if w and w.render_info: + # Put cursor at the first visible line. (But make sure that the cursor + # moves at least one line up.) + line_index = max( + 0, + min(w.render_info.first_visible_line(), b.document.cursor_position_row - 1), + ) + + b.cursor_position = b.document.translate_row_col_to_index(line_index, 0) + b.cursor_position += b.document.get_start_of_line_position( + after_whitespace=True + ) + + # Set the scroll offset. We can safely set it to zero; the Window will + # make sure that it scrolls at least until the cursor becomes visible. + w.vertical_scroll = 0 diff --git a/src/prompt_toolkit/key_binding/bindings/search.py b/src/prompt_toolkit/key_binding/bindings/search.py new file mode 100644 index 0000000..ba5e117 --- /dev/null +++ b/src/prompt_toolkit/key_binding/bindings/search.py @@ -0,0 +1,95 @@ +""" +Search related key bindings. +""" +from __future__ import annotations + +from prompt_toolkit import search +from prompt_toolkit.application.current import get_app +from prompt_toolkit.filters import Condition, control_is_searchable, is_searching +from prompt_toolkit.key_binding.key_processor import KeyPressEvent + +from ..key_bindings import key_binding + +__all__ = [ + "abort_search", + "accept_search", + "start_reverse_incremental_search", + "start_forward_incremental_search", + "reverse_incremental_search", + "forward_incremental_search", + "accept_search_and_accept_input", +] + +E = KeyPressEvent + + +@key_binding(filter=is_searching) +def abort_search(event: E) -> None: + """ + Abort an incremental search and restore the original + line. + (Usually bound to ControlG/ControlC.) + """ + search.stop_search() + + +@key_binding(filter=is_searching) +def accept_search(event: E) -> None: + """ + When enter pressed in isearch, quit isearch mode. (Multiline + isearch would be too complicated.) + (Usually bound to Enter.) + """ + search.accept_search() + + +@key_binding(filter=control_is_searchable) +def start_reverse_incremental_search(event: E) -> None: + """ + Enter reverse incremental search. + (Usually ControlR.) + """ + search.start_search(direction=search.SearchDirection.BACKWARD) + + +@key_binding(filter=control_is_searchable) +def start_forward_incremental_search(event: E) -> None: + """ + Enter forward incremental search. + (Usually ControlS.) + """ + search.start_search(direction=search.SearchDirection.FORWARD) + + +@key_binding(filter=is_searching) +def reverse_incremental_search(event: E) -> None: + """ + Apply reverse incremental search, but keep search buffer focused. + """ + search.do_incremental_search(search.SearchDirection.BACKWARD, count=event.arg) + + +@key_binding(filter=is_searching) +def forward_incremental_search(event: E) -> None: + """ + Apply forward incremental search, but keep search buffer focused. + """ + search.do_incremental_search(search.SearchDirection.FORWARD, count=event.arg) + + +@Condition +def _previous_buffer_is_returnable() -> bool: + """ + True if the previously focused buffer has a return handler. + """ + prev_control = get_app().layout.search_target_buffer_control + return bool(prev_control and prev_control.buffer.is_returnable) + + +@key_binding(filter=is_searching & _previous_buffer_is_returnable) +def accept_search_and_accept_input(event: E) -> None: + """ + Accept the search operation first, then accept the input. + """ + search.accept_search() + event.current_buffer.validate_and_handle() diff --git a/src/prompt_toolkit/key_binding/bindings/vi.py b/src/prompt_toolkit/key_binding/bindings/vi.py new file mode 100644 index 0000000..5cc74b4 --- /dev/null +++ b/src/prompt_toolkit/key_binding/bindings/vi.py @@ -0,0 +1,2224 @@ +# pylint: disable=function-redefined +from __future__ import annotations + +import codecs +import string +from enum import Enum +from itertools import accumulate +from typing import Callable, Iterable, Tuple, TypeVar + +from prompt_toolkit.application.current import get_app +from prompt_toolkit.buffer import Buffer, indent, reshape_text, unindent +from prompt_toolkit.clipboard import ClipboardData +from prompt_toolkit.document import Document +from prompt_toolkit.filters import ( + Always, + Condition, + Filter, + has_arg, + is_read_only, + is_searching, +) +from prompt_toolkit.filters.app import ( + in_paste_mode, + is_multiline, + vi_digraph_mode, + vi_insert_mode, + vi_insert_multiple_mode, + vi_mode, + vi_navigation_mode, + vi_recording_macro, + vi_replace_mode, + vi_replace_single_mode, + vi_search_direction_reversed, + vi_selection_mode, + vi_waiting_for_text_object_mode, +) +from prompt_toolkit.input.vt100_parser import Vt100Parser +from prompt_toolkit.key_binding.digraphs import DIGRAPHS +from prompt_toolkit.key_binding.key_processor import KeyPress, KeyPressEvent +from prompt_toolkit.key_binding.vi_state import CharacterFind, InputMode +from prompt_toolkit.keys import Keys +from prompt_toolkit.search import SearchDirection +from prompt_toolkit.selection import PasteMode, SelectionState, SelectionType + +from ..key_bindings import ConditionalKeyBindings, KeyBindings, KeyBindingsBase +from .named_commands import get_by_name + +__all__ = [ + "load_vi_bindings", + "load_vi_search_bindings", +] + +E = KeyPressEvent + +ascii_lowercase = string.ascii_lowercase + +vi_register_names = ascii_lowercase + "0123456789" + + +class TextObjectType(Enum): + EXCLUSIVE = "EXCLUSIVE" + INCLUSIVE = "INCLUSIVE" + LINEWISE = "LINEWISE" + BLOCK = "BLOCK" + + +class TextObject: + """ + Return struct for functions wrapped in ``text_object``. + Both `start` and `end` are relative to the current cursor position. + """ + + def __init__( + self, start: int, end: int = 0, type: TextObjectType = TextObjectType.EXCLUSIVE + ): + self.start = start + self.end = end + self.type = type + + @property + def selection_type(self) -> SelectionType: + if self.type == TextObjectType.LINEWISE: + return SelectionType.LINES + if self.type == TextObjectType.BLOCK: + return SelectionType.BLOCK + else: + return SelectionType.CHARACTERS + + def sorted(self) -> tuple[int, int]: + """ + Return a (start, end) tuple where start <= end. + """ + if self.start < self.end: + return self.start, self.end + else: + return self.end, self.start + + def operator_range(self, document: Document) -> tuple[int, int]: + """ + Return a (start, end) tuple with start <= end that indicates the range + operators should operate on. + `buffer` is used to get start and end of line positions. + + This should return something that can be used in a slice, so the `end` + position is *not* included. + """ + start, end = self.sorted() + doc = document + + if ( + self.type == TextObjectType.EXCLUSIVE + and doc.translate_index_to_position(end + doc.cursor_position)[1] == 0 + ): + # If the motion is exclusive and the end of motion is on the first + # column, the end position becomes end of previous line. + end -= 1 + if self.type == TextObjectType.INCLUSIVE: + end += 1 + if self.type == TextObjectType.LINEWISE: + # Select whole lines + row, col = doc.translate_index_to_position(start + doc.cursor_position) + start = doc.translate_row_col_to_index(row, 0) - doc.cursor_position + row, col = doc.translate_index_to_position(end + doc.cursor_position) + end = ( + doc.translate_row_col_to_index(row, len(doc.lines[row])) + - doc.cursor_position + ) + return start, end + + def get_line_numbers(self, buffer: Buffer) -> tuple[int, int]: + """ + Return a (start_line, end_line) pair. + """ + # Get absolute cursor positions from the text object. + from_, to = self.operator_range(buffer.document) + from_ += buffer.cursor_position + to += buffer.cursor_position + + # Take the start of the lines. + from_, _ = buffer.document.translate_index_to_position(from_) + to, _ = buffer.document.translate_index_to_position(to) + + return from_, to + + def cut(self, buffer: Buffer) -> tuple[Document, ClipboardData]: + """ + Turn text object into `ClipboardData` instance. + """ + from_, to = self.operator_range(buffer.document) + + from_ += buffer.cursor_position + to += buffer.cursor_position + + # For Vi mode, the SelectionState does include the upper position, + # while `self.operator_range` does not. So, go one to the left, unless + # we're in the line mode, then we don't want to risk going to the + # previous line, and missing one line in the selection. + if self.type != TextObjectType.LINEWISE: + to -= 1 + + document = Document( + buffer.text, + to, + SelectionState(original_cursor_position=from_, type=self.selection_type), + ) + + new_document, clipboard_data = document.cut_selection() + return new_document, clipboard_data + + +# Typevar for any text object function: +TextObjectFunction = Callable[[E], TextObject] +_TOF = TypeVar("_TOF", bound=TextObjectFunction) + + +def create_text_object_decorator( + key_bindings: KeyBindings, +) -> Callable[..., Callable[[_TOF], _TOF]]: + """ + Create a decorator that can be used to register Vi text object implementations. + """ + + def text_object_decorator( + *keys: Keys | str, + filter: Filter = Always(), + no_move_handler: bool = False, + no_selection_handler: bool = False, + eager: bool = False, + ) -> Callable[[_TOF], _TOF]: + """ + Register a text object function. + + Usage:: + + @text_object('w', filter=..., no_move_handler=False) + def handler(event): + # Return a text object for this key. + return TextObject(...) + + :param no_move_handler: Disable the move handler in navigation mode. + (It's still active in selection mode.) + """ + + def decorator(text_object_func: _TOF) -> _TOF: + @key_bindings.add( + *keys, filter=vi_waiting_for_text_object_mode & filter, eager=eager + ) + def _apply_operator_to_text_object(event: E) -> None: + # Arguments are multiplied. + vi_state = event.app.vi_state + event._arg = str((vi_state.operator_arg or 1) * (event.arg or 1)) + + # Call the text object handler. + text_obj = text_object_func(event) + + # Get the operator function. + # (Should never be None here, given the + # `vi_waiting_for_text_object_mode` filter state.) + operator_func = vi_state.operator_func + + if text_obj is not None and operator_func is not None: + # Call the operator function with the text object. + operator_func(event, text_obj) + + # Clear operator. + event.app.vi_state.operator_func = None + event.app.vi_state.operator_arg = None + + # Register a move operation. (Doesn't need an operator.) + if not no_move_handler: + + @key_bindings.add( + *keys, + filter=~vi_waiting_for_text_object_mode + & filter + & vi_navigation_mode, + eager=eager, + ) + def _move_in_navigation_mode(event: E) -> None: + """ + Move handler for navigation mode. + """ + text_object = text_object_func(event) + event.current_buffer.cursor_position += text_object.start + + # Register a move selection operation. + if not no_selection_handler: + + @key_bindings.add( + *keys, + filter=~vi_waiting_for_text_object_mode + & filter + & vi_selection_mode, + eager=eager, + ) + def _move_in_selection_mode(event: E) -> None: + """ + Move handler for selection mode. + """ + text_object = text_object_func(event) + buff = event.current_buffer + selection_state = buff.selection_state + + if selection_state is None: + return # Should not happen, because of the `vi_selection_mode` filter. + + # When the text object has both a start and end position, like 'i(' or 'iw', + # Turn this into a selection, otherwise the cursor. + if text_object.end: + # Take selection positions from text object. + start, end = text_object.operator_range(buff.document) + start += buff.cursor_position + end += buff.cursor_position + + selection_state.original_cursor_position = start + buff.cursor_position = end + + # Take selection type from text object. + if text_object.type == TextObjectType.LINEWISE: + selection_state.type = SelectionType.LINES + else: + selection_state.type = SelectionType.CHARACTERS + else: + event.current_buffer.cursor_position += text_object.start + + # Make it possible to chain @text_object decorators. + return text_object_func + + return decorator + + return text_object_decorator + + +# Typevar for any operator function: +OperatorFunction = Callable[[E, TextObject], None] +_OF = TypeVar("_OF", bound=OperatorFunction) + + +def create_operator_decorator( + key_bindings: KeyBindings, +) -> Callable[..., Callable[[_OF], _OF]]: + """ + Create a decorator that can be used for registering Vi operators. + """ + + def operator_decorator( + *keys: Keys | str, filter: Filter = Always(), eager: bool = False + ) -> Callable[[_OF], _OF]: + """ + Register a Vi operator. + + Usage:: + + @operator('d', filter=...) + def handler(event, text_object): + # Do something with the text object here. + """ + + def decorator(operator_func: _OF) -> _OF: + @key_bindings.add( + *keys, + filter=~vi_waiting_for_text_object_mode & filter & vi_navigation_mode, + eager=eager, + ) + def _operator_in_navigation(event: E) -> None: + """ + Handle operator in navigation mode. + """ + # When this key binding is matched, only set the operator + # function in the ViState. We should execute it after a text + # object has been received. + event.app.vi_state.operator_func = operator_func + event.app.vi_state.operator_arg = event.arg + + @key_bindings.add( + *keys, + filter=~vi_waiting_for_text_object_mode & filter & vi_selection_mode, + eager=eager, + ) + def _operator_in_selection(event: E) -> None: + """ + Handle operator in selection mode. + """ + buff = event.current_buffer + selection_state = buff.selection_state + + if selection_state is not None: + # Create text object from selection. + if selection_state.type == SelectionType.LINES: + text_obj_type = TextObjectType.LINEWISE + elif selection_state.type == SelectionType.BLOCK: + text_obj_type = TextObjectType.BLOCK + else: + text_obj_type = TextObjectType.INCLUSIVE + + text_object = TextObject( + selection_state.original_cursor_position - buff.cursor_position, + type=text_obj_type, + ) + + # Execute operator. + operator_func(event, text_object) + + # Quit selection mode. + buff.selection_state = None + + return operator_func + + return decorator + + return operator_decorator + + +def load_vi_bindings() -> KeyBindingsBase: + """ + Vi extensions. + + # Overview of Readline Vi commands: + # http://www.catonmat.net/download/bash-vi-editing-mode-cheat-sheet.pdf + """ + # Note: Some key bindings have the "~IsReadOnly()" filter added. This + # prevents the handler to be executed when the focus is on a + # read-only buffer. + # This is however only required for those that change the ViState to + # INSERT mode. The `Buffer` class itself throws the + # `EditReadOnlyBuffer` exception for any text operations which is + # handled correctly. There is no need to add "~IsReadOnly" to all key + # bindings that do text manipulation. + + key_bindings = KeyBindings() + handle = key_bindings.add + + # (Note: Always take the navigation bindings in read-only mode, even when + # ViState says different.) + + TransformFunction = Tuple[Tuple[str, ...], Filter, Callable[[str], str]] + + vi_transform_functions: list[TransformFunction] = [ + # Rot 13 transformation + ( + ("g", "?"), + Always(), + lambda string: codecs.encode(string, "rot_13"), + ), + # To lowercase + (("g", "u"), Always(), lambda string: string.lower()), + # To uppercase. + (("g", "U"), Always(), lambda string: string.upper()), + # Swap case. + (("g", "~"), Always(), lambda string: string.swapcase()), + ( + ("~",), + Condition(lambda: get_app().vi_state.tilde_operator), + lambda string: string.swapcase(), + ), + ] + + # Insert a character literally (quoted insert). + handle("c-v", filter=vi_insert_mode)(get_by_name("quoted-insert")) + + @handle("escape") + def _back_to_navigation(event: E) -> None: + """ + Escape goes to vi navigation mode. + """ + buffer = event.current_buffer + vi_state = event.app.vi_state + + if vi_state.input_mode in (InputMode.INSERT, InputMode.REPLACE): + buffer.cursor_position += buffer.document.get_cursor_left_position() + + vi_state.input_mode = InputMode.NAVIGATION + + if bool(buffer.selection_state): + buffer.exit_selection() + + @handle("k", filter=vi_selection_mode) + def _up_in_selection(event: E) -> None: + """ + Arrow up in selection mode. + """ + event.current_buffer.cursor_up(count=event.arg) + + @handle("j", filter=vi_selection_mode) + def _down_in_selection(event: E) -> None: + """ + Arrow down in selection mode. + """ + event.current_buffer.cursor_down(count=event.arg) + + @handle("up", filter=vi_navigation_mode) + @handle("c-p", filter=vi_navigation_mode) + def _up_in_navigation(event: E) -> None: + """ + Arrow up and ControlP in navigation mode go up. + """ + event.current_buffer.auto_up(count=event.arg) + + @handle("k", filter=vi_navigation_mode) + def _go_up(event: E) -> None: + """ + Go up, but if we enter a new history entry, move to the start of the + line. + """ + event.current_buffer.auto_up( + count=event.arg, go_to_start_of_line_if_history_changes=True + ) + + @handle("down", filter=vi_navigation_mode) + @handle("c-n", filter=vi_navigation_mode) + def _go_down(event: E) -> None: + """ + Arrow down and Control-N in navigation mode. + """ + event.current_buffer.auto_down(count=event.arg) + + @handle("j", filter=vi_navigation_mode) + def _go_down2(event: E) -> None: + """ + Go down, but if we enter a new history entry, go to the start of the line. + """ + event.current_buffer.auto_down( + count=event.arg, go_to_start_of_line_if_history_changes=True + ) + + @handle("backspace", filter=vi_navigation_mode) + def _go_left(event: E) -> None: + """ + In navigation-mode, move cursor. + """ + event.current_buffer.cursor_position += ( + event.current_buffer.document.get_cursor_left_position(count=event.arg) + ) + + @handle("c-n", filter=vi_insert_mode) + def _complete_next(event: E) -> None: + b = event.current_buffer + + if b.complete_state: + b.complete_next() + else: + b.start_completion(select_first=True) + + @handle("c-p", filter=vi_insert_mode) + def _complete_prev(event: E) -> None: + """ + Control-P: To previous completion. + """ + b = event.current_buffer + + if b.complete_state: + b.complete_previous() + else: + b.start_completion(select_last=True) + + @handle("c-g", filter=vi_insert_mode) + @handle("c-y", filter=vi_insert_mode) + def _accept_completion(event: E) -> None: + """ + Accept current completion. + """ + event.current_buffer.complete_state = None + + @handle("c-e", filter=vi_insert_mode) + def _cancel_completion(event: E) -> None: + """ + Cancel completion. Go back to originally typed text. + """ + event.current_buffer.cancel_completion() + + @Condition + def is_returnable() -> bool: + return get_app().current_buffer.is_returnable + + # In navigation mode, pressing enter will always return the input. + handle("enter", filter=vi_navigation_mode & is_returnable)( + get_by_name("accept-line") + ) + + # In insert mode, also accept input when enter is pressed, and the buffer + # has been marked as single line. + handle("enter", filter=is_returnable & ~is_multiline)(get_by_name("accept-line")) + + @handle("enter", filter=~is_returnable & vi_navigation_mode) + def _start_of_next_line(event: E) -> None: + """ + Go to the beginning of next line. + """ + b = event.current_buffer + b.cursor_down(count=event.arg) + b.cursor_position += b.document.get_start_of_line_position( + after_whitespace=True + ) + + # ** In navigation mode ** + + # List of navigation commands: http://hea-www.harvard.edu/~fine/Tech/vi.html + + @handle("insert", filter=vi_navigation_mode) + def _insert_mode(event: E) -> None: + """ + Pressing the Insert key. + """ + event.app.vi_state.input_mode = InputMode.INSERT + + @handle("insert", filter=vi_insert_mode) + def _navigation_mode(event: E) -> None: + """ + Pressing the Insert key. + """ + event.app.vi_state.input_mode = InputMode.NAVIGATION + + @handle("a", filter=vi_navigation_mode & ~is_read_only) + # ~IsReadOnly, because we want to stay in navigation mode for + # read-only buffers. + def _a(event: E) -> None: + event.current_buffer.cursor_position += ( + event.current_buffer.document.get_cursor_right_position() + ) + event.app.vi_state.input_mode = InputMode.INSERT + + @handle("A", filter=vi_navigation_mode & ~is_read_only) + def _A(event: E) -> None: + event.current_buffer.cursor_position += ( + event.current_buffer.document.get_end_of_line_position() + ) + event.app.vi_state.input_mode = InputMode.INSERT + + @handle("C", filter=vi_navigation_mode & ~is_read_only) + def _change_until_end_of_line(event: E) -> None: + """ + Change to end of line. + Same as 'c$' (which is implemented elsewhere.) + """ + buffer = event.current_buffer + + deleted = buffer.delete(count=buffer.document.get_end_of_line_position()) + event.app.clipboard.set_text(deleted) + event.app.vi_state.input_mode = InputMode.INSERT + + @handle("c", "c", filter=vi_navigation_mode & ~is_read_only) + @handle("S", filter=vi_navigation_mode & ~is_read_only) + def _change_current_line(event: E) -> None: # TODO: implement 'arg' + """ + Change current line + """ + buffer = event.current_buffer + + # We copy the whole line. + data = ClipboardData(buffer.document.current_line, SelectionType.LINES) + event.app.clipboard.set_data(data) + + # But we delete after the whitespace + buffer.cursor_position += buffer.document.get_start_of_line_position( + after_whitespace=True + ) + buffer.delete(count=buffer.document.get_end_of_line_position()) + event.app.vi_state.input_mode = InputMode.INSERT + + @handle("D", filter=vi_navigation_mode) + def _delete_until_end_of_line(event: E) -> None: + """ + Delete from cursor position until the end of the line. + """ + buffer = event.current_buffer + deleted = buffer.delete(count=buffer.document.get_end_of_line_position()) + event.app.clipboard.set_text(deleted) + + @handle("d", "d", filter=vi_navigation_mode) + def _delete_line(event: E) -> None: + """ + Delete line. (Or the following 'n' lines.) + """ + buffer = event.current_buffer + + # Split string in before/deleted/after text. + lines = buffer.document.lines + + before = "\n".join(lines[: buffer.document.cursor_position_row]) + deleted = "\n".join( + lines[ + buffer.document.cursor_position_row : buffer.document.cursor_position_row + + event.arg + ] + ) + after = "\n".join(lines[buffer.document.cursor_position_row + event.arg :]) + + # Set new text. + if before and after: + before = before + "\n" + + # Set text and cursor position. + buffer.document = Document( + text=before + after, + # Cursor At the start of the first 'after' line, after the leading whitespace. + cursor_position=len(before) + len(after) - len(after.lstrip(" ")), + ) + + # Set clipboard data + event.app.clipboard.set_data(ClipboardData(deleted, SelectionType.LINES)) + + @handle("x", filter=vi_selection_mode) + def _cut(event: E) -> None: + """ + Cut selection. + ('x' is not an operator.) + """ + clipboard_data = event.current_buffer.cut_selection() + event.app.clipboard.set_data(clipboard_data) + + @handle("i", filter=vi_navigation_mode & ~is_read_only) + def _i(event: E) -> None: + event.app.vi_state.input_mode = InputMode.INSERT + + @handle("I", filter=vi_navigation_mode & ~is_read_only) + def _I(event: E) -> None: + event.app.vi_state.input_mode = InputMode.INSERT + event.current_buffer.cursor_position += ( + event.current_buffer.document.get_start_of_line_position( + after_whitespace=True + ) + ) + + @Condition + def in_block_selection() -> bool: + buff = get_app().current_buffer + return bool( + buff.selection_state and buff.selection_state.type == SelectionType.BLOCK + ) + + @handle("I", filter=in_block_selection & ~is_read_only) + def insert_in_block_selection(event: E, after: bool = False) -> None: + """ + Insert in block selection mode. + """ + buff = event.current_buffer + + # Store all cursor positions. + positions = [] + + if after: + + def get_pos(from_to: tuple[int, int]) -> int: + return from_to[1] + + else: + + def get_pos(from_to: tuple[int, int]) -> int: + return from_to[0] + + for i, from_to in enumerate(buff.document.selection_ranges()): + positions.append(get_pos(from_to)) + if i == 0: + buff.cursor_position = get_pos(from_to) + + buff.multiple_cursor_positions = positions + + # Go to 'INSERT_MULTIPLE' mode. + event.app.vi_state.input_mode = InputMode.INSERT_MULTIPLE + buff.exit_selection() + + @handle("A", filter=in_block_selection & ~is_read_only) + def _append_after_block(event: E) -> None: + insert_in_block_selection(event, after=True) + + @handle("J", filter=vi_navigation_mode & ~is_read_only) + def _join(event: E) -> None: + """ + Join lines. + """ + for i in range(event.arg): + event.current_buffer.join_next_line() + + @handle("g", "J", filter=vi_navigation_mode & ~is_read_only) + def _join_nospace(event: E) -> None: + """ + Join lines without space. + """ + for i in range(event.arg): + event.current_buffer.join_next_line(separator="") + + @handle("J", filter=vi_selection_mode & ~is_read_only) + def _join_selection(event: E) -> None: + """ + Join selected lines. + """ + event.current_buffer.join_selected_lines() + + @handle("g", "J", filter=vi_selection_mode & ~is_read_only) + def _join_selection_nospace(event: E) -> None: + """ + Join selected lines without space. + """ + event.current_buffer.join_selected_lines(separator="") + + @handle("p", filter=vi_navigation_mode) + def _paste(event: E) -> None: + """ + Paste after + """ + event.current_buffer.paste_clipboard_data( + event.app.clipboard.get_data(), + count=event.arg, + paste_mode=PasteMode.VI_AFTER, + ) + + @handle("P", filter=vi_navigation_mode) + def _paste_before(event: E) -> None: + """ + Paste before + """ + event.current_buffer.paste_clipboard_data( + event.app.clipboard.get_data(), + count=event.arg, + paste_mode=PasteMode.VI_BEFORE, + ) + + @handle('"', Keys.Any, "p", filter=vi_navigation_mode) + def _paste_register(event: E) -> None: + """ + Paste from named register. + """ + c = event.key_sequence[1].data + if c in vi_register_names: + data = event.app.vi_state.named_registers.get(c) + if data: + event.current_buffer.paste_clipboard_data( + data, count=event.arg, paste_mode=PasteMode.VI_AFTER + ) + + @handle('"', Keys.Any, "P", filter=vi_navigation_mode) + def _paste_register_before(event: E) -> None: + """ + Paste (before) from named register. + """ + c = event.key_sequence[1].data + if c in vi_register_names: + data = event.app.vi_state.named_registers.get(c) + if data: + event.current_buffer.paste_clipboard_data( + data, count=event.arg, paste_mode=PasteMode.VI_BEFORE + ) + + @handle("r", filter=vi_navigation_mode) + def _replace(event: E) -> None: + """ + Go to 'replace-single'-mode. + """ + event.app.vi_state.input_mode = InputMode.REPLACE_SINGLE + + @handle("R", filter=vi_navigation_mode) + def _replace_mode(event: E) -> None: + """ + Go to 'replace'-mode. + """ + event.app.vi_state.input_mode = InputMode.REPLACE + + @handle("s", filter=vi_navigation_mode & ~is_read_only) + def _substitute(event: E) -> None: + """ + Substitute with new text + (Delete character(s) and go to insert mode.) + """ + text = event.current_buffer.delete(count=event.arg) + event.app.clipboard.set_text(text) + event.app.vi_state.input_mode = InputMode.INSERT + + @handle("u", filter=vi_navigation_mode, save_before=(lambda e: False)) + def _undo(event: E) -> None: + for i in range(event.arg): + event.current_buffer.undo() + + @handle("V", filter=vi_navigation_mode) + def _visual_line(event: E) -> None: + """ + Start lines selection. + """ + event.current_buffer.start_selection(selection_type=SelectionType.LINES) + + @handle("c-v", filter=vi_navigation_mode) + def _visual_block(event: E) -> None: + """ + Enter block selection mode. + """ + event.current_buffer.start_selection(selection_type=SelectionType.BLOCK) + + @handle("V", filter=vi_selection_mode) + def _visual_line2(event: E) -> None: + """ + Exit line selection mode, or go from non line selection mode to line + selection mode. + """ + selection_state = event.current_buffer.selection_state + + if selection_state is not None: + if selection_state.type != SelectionType.LINES: + selection_state.type = SelectionType.LINES + else: + event.current_buffer.exit_selection() + + @handle("v", filter=vi_navigation_mode) + def _visual(event: E) -> None: + """ + Enter character selection mode. + """ + event.current_buffer.start_selection(selection_type=SelectionType.CHARACTERS) + + @handle("v", filter=vi_selection_mode) + def _visual2(event: E) -> None: + """ + Exit character selection mode, or go from non-character-selection mode + to character selection mode. + """ + selection_state = event.current_buffer.selection_state + + if selection_state is not None: + if selection_state.type != SelectionType.CHARACTERS: + selection_state.type = SelectionType.CHARACTERS + else: + event.current_buffer.exit_selection() + + @handle("c-v", filter=vi_selection_mode) + def _visual_block2(event: E) -> None: + """ + Exit block selection mode, or go from non block selection mode to block + selection mode. + """ + selection_state = event.current_buffer.selection_state + + if selection_state is not None: + if selection_state.type != SelectionType.BLOCK: + selection_state.type = SelectionType.BLOCK + else: + event.current_buffer.exit_selection() + + @handle("a", "w", filter=vi_selection_mode) + @handle("a", "W", filter=vi_selection_mode) + def _visual_auto_word(event: E) -> None: + """ + Switch from visual linewise mode to visual characterwise mode. + """ + buffer = event.current_buffer + + if ( + buffer.selection_state + and buffer.selection_state.type == SelectionType.LINES + ): + buffer.selection_state.type = SelectionType.CHARACTERS + + @handle("x", filter=vi_navigation_mode) + def _delete(event: E) -> None: + """ + Delete character. + """ + buff = event.current_buffer + count = min(event.arg, len(buff.document.current_line_after_cursor)) + if count: + text = event.current_buffer.delete(count=count) + event.app.clipboard.set_text(text) + + @handle("X", filter=vi_navigation_mode) + def _delete_before_cursor(event: E) -> None: + buff = event.current_buffer + count = min(event.arg, len(buff.document.current_line_before_cursor)) + if count: + text = event.current_buffer.delete_before_cursor(count=count) + event.app.clipboard.set_text(text) + + @handle("y", "y", filter=vi_navigation_mode) + @handle("Y", filter=vi_navigation_mode) + def _yank_line(event: E) -> None: + """ + Yank the whole line. + """ + text = "\n".join(event.current_buffer.document.lines_from_current[: event.arg]) + event.app.clipboard.set_data(ClipboardData(text, SelectionType.LINES)) + + @handle("+", filter=vi_navigation_mode) + def _next_line(event: E) -> None: + """ + Move to first non whitespace of next line + """ + buffer = event.current_buffer + buffer.cursor_position += buffer.document.get_cursor_down_position( + count=event.arg + ) + buffer.cursor_position += buffer.document.get_start_of_line_position( + after_whitespace=True + ) + + @handle("-", filter=vi_navigation_mode) + def _prev_line(event: E) -> None: + """ + Move to first non whitespace of previous line + """ + buffer = event.current_buffer + buffer.cursor_position += buffer.document.get_cursor_up_position( + count=event.arg + ) + buffer.cursor_position += buffer.document.get_start_of_line_position( + after_whitespace=True + ) + + @handle(">", ">", filter=vi_navigation_mode) + @handle("c-t", filter=vi_insert_mode) + def _indent(event: E) -> None: + """ + Indent lines. + """ + buffer = event.current_buffer + current_row = buffer.document.cursor_position_row + indent(buffer, current_row, current_row + event.arg) + + @handle("<", "<", filter=vi_navigation_mode) + @handle("c-d", filter=vi_insert_mode) + def _unindent(event: E) -> None: + """ + Unindent lines. + """ + current_row = event.current_buffer.document.cursor_position_row + unindent(event.current_buffer, current_row, current_row + event.arg) + + @handle("O", filter=vi_navigation_mode & ~is_read_only) + def _open_above(event: E) -> None: + """ + Open line above and enter insertion mode + """ + event.current_buffer.insert_line_above(copy_margin=not in_paste_mode()) + event.app.vi_state.input_mode = InputMode.INSERT + + @handle("o", filter=vi_navigation_mode & ~is_read_only) + def _open_below(event: E) -> None: + """ + Open line below and enter insertion mode + """ + event.current_buffer.insert_line_below(copy_margin=not in_paste_mode()) + event.app.vi_state.input_mode = InputMode.INSERT + + @handle("~", filter=vi_navigation_mode) + def _reverse_case(event: E) -> None: + """ + Reverse case of current character and move cursor forward. + """ + buffer = event.current_buffer + c = buffer.document.current_char + + if c is not None and c != "\n": + buffer.insert_text(c.swapcase(), overwrite=True) + + @handle("g", "u", "u", filter=vi_navigation_mode & ~is_read_only) + def _lowercase_line(event: E) -> None: + """ + Lowercase current line. + """ + buff = event.current_buffer + buff.transform_current_line(lambda s: s.lower()) + + @handle("g", "U", "U", filter=vi_navigation_mode & ~is_read_only) + def _uppercase_line(event: E) -> None: + """ + Uppercase current line. + """ + buff = event.current_buffer + buff.transform_current_line(lambda s: s.upper()) + + @handle("g", "~", "~", filter=vi_navigation_mode & ~is_read_only) + def _swapcase_line(event: E) -> None: + """ + Swap case of the current line. + """ + buff = event.current_buffer + buff.transform_current_line(lambda s: s.swapcase()) + + @handle("#", filter=vi_navigation_mode) + def _prev_occurrence(event: E) -> None: + """ + Go to previous occurrence of this word. + """ + b = event.current_buffer + search_state = event.app.current_search_state + + search_state.text = b.document.get_word_under_cursor() + search_state.direction = SearchDirection.BACKWARD + + b.apply_search(search_state, count=event.arg, include_current_position=False) + + @handle("*", filter=vi_navigation_mode) + def _next_occurrence(event: E) -> None: + """ + Go to next occurrence of this word. + """ + b = event.current_buffer + search_state = event.app.current_search_state + + search_state.text = b.document.get_word_under_cursor() + search_state.direction = SearchDirection.FORWARD + + b.apply_search(search_state, count=event.arg, include_current_position=False) + + @handle("(", filter=vi_navigation_mode) + def _begin_of_sentence(event: E) -> None: + # TODO: go to begin of sentence. + # XXX: should become text_object. + pass + + @handle(")", filter=vi_navigation_mode) + def _end_of_sentence(event: E) -> None: + # TODO: go to end of sentence. + # XXX: should become text_object. + pass + + operator = create_operator_decorator(key_bindings) + text_object = create_text_object_decorator(key_bindings) + + @handle(Keys.Any, filter=vi_waiting_for_text_object_mode) + def _unknown_text_object(event: E) -> None: + """ + Unknown key binding while waiting for a text object. + """ + event.app.output.bell() + + # + # *** Operators *** + # + + def create_delete_and_change_operators( + delete_only: bool, with_register: bool = False + ) -> None: + """ + Delete and change operators. + + :param delete_only: Create an operator that deletes, but doesn't go to insert mode. + :param with_register: Copy the deleted text to this named register instead of the clipboard. + """ + handler_keys: Iterable[str] + if with_register: + handler_keys = ('"', Keys.Any, "cd"[delete_only]) + else: + handler_keys = "cd"[delete_only] + + @operator(*handler_keys, filter=~is_read_only) + def delete_or_change_operator(event: E, text_object: TextObject) -> None: + clipboard_data = None + buff = event.current_buffer + + if text_object: + new_document, clipboard_data = text_object.cut(buff) + buff.document = new_document + + # Set deleted/changed text to clipboard or named register. + if clipboard_data and clipboard_data.text: + if with_register: + reg_name = event.key_sequence[1].data + if reg_name in vi_register_names: + event.app.vi_state.named_registers[reg_name] = clipboard_data + else: + event.app.clipboard.set_data(clipboard_data) + + # Only go back to insert mode in case of 'change'. + if not delete_only: + event.app.vi_state.input_mode = InputMode.INSERT + + create_delete_and_change_operators(False, False) + create_delete_and_change_operators(False, True) + create_delete_and_change_operators(True, False) + create_delete_and_change_operators(True, True) + + def create_transform_handler( + filter: Filter, transform_func: Callable[[str], str], *a: str + ) -> None: + @operator(*a, filter=filter & ~is_read_only) + def _(event: E, text_object: TextObject) -> None: + """ + Apply transformation (uppercase, lowercase, rot13, swap case). + """ + buff = event.current_buffer + start, end = text_object.operator_range(buff.document) + + if start < end: + # Transform. + buff.transform_region( + buff.cursor_position + start, + buff.cursor_position + end, + transform_func, + ) + + # Move cursor + buff.cursor_position += text_object.end or text_object.start + + for k, f, func in vi_transform_functions: + create_transform_handler(f, func, *k) + + @operator("y") + def _yank(event: E, text_object: TextObject) -> None: + """ + Yank operator. (Copy text.) + """ + _, clipboard_data = text_object.cut(event.current_buffer) + if clipboard_data.text: + event.app.clipboard.set_data(clipboard_data) + + @operator('"', Keys.Any, "y") + def _yank_to_register(event: E, text_object: TextObject) -> None: + """ + Yank selection to named register. + """ + c = event.key_sequence[1].data + if c in vi_register_names: + _, clipboard_data = text_object.cut(event.current_buffer) + event.app.vi_state.named_registers[c] = clipboard_data + + @operator(">") + def _indent_text_object(event: E, text_object: TextObject) -> None: + """ + Indent. + """ + buff = event.current_buffer + from_, to = text_object.get_line_numbers(buff) + indent(buff, from_, to + 1, count=event.arg) + + @operator("<") + def _unindent_text_object(event: E, text_object: TextObject) -> None: + """ + Unindent. + """ + buff = event.current_buffer + from_, to = text_object.get_line_numbers(buff) + unindent(buff, from_, to + 1, count=event.arg) + + @operator("g", "q") + def _reshape(event: E, text_object: TextObject) -> None: + """ + Reshape text. + """ + buff = event.current_buffer + from_, to = text_object.get_line_numbers(buff) + reshape_text(buff, from_, to) + + # + # *** Text objects *** + # + + @text_object("b") + def _b(event: E) -> TextObject: + """ + Move one word or token left. + """ + return TextObject( + event.current_buffer.document.find_start_of_previous_word(count=event.arg) + or 0 + ) + + @text_object("B") + def _B(event: E) -> TextObject: + """ + Move one non-blank word left + """ + return TextObject( + event.current_buffer.document.find_start_of_previous_word( + count=event.arg, WORD=True + ) + or 0 + ) + + @text_object("$") + def _dollar(event: E) -> TextObject: + """ + 'c$', 'd$' and '$': Delete/change/move until end of line. + """ + return TextObject(event.current_buffer.document.get_end_of_line_position()) + + @text_object("w") + def _word_forward(event: E) -> TextObject: + """ + 'word' forward. 'cw', 'dw', 'w': Delete/change/move one word. + """ + return TextObject( + event.current_buffer.document.find_next_word_beginning(count=event.arg) + or event.current_buffer.document.get_end_of_document_position() + ) + + @text_object("W") + def _WORD_forward(event: E) -> TextObject: + """ + 'WORD' forward. 'cW', 'dW', 'W': Delete/change/move one WORD. + """ + return TextObject( + event.current_buffer.document.find_next_word_beginning( + count=event.arg, WORD=True + ) + or event.current_buffer.document.get_end_of_document_position() + ) + + @text_object("e") + def _end_of_word(event: E) -> TextObject: + """ + End of 'word': 'ce', 'de', 'e' + """ + end = event.current_buffer.document.find_next_word_ending(count=event.arg) + return TextObject(end - 1 if end else 0, type=TextObjectType.INCLUSIVE) + + @text_object("E") + def _end_of_WORD(event: E) -> TextObject: + """ + End of 'WORD': 'cE', 'dE', 'E' + """ + end = event.current_buffer.document.find_next_word_ending( + count=event.arg, WORD=True + ) + return TextObject(end - 1 if end else 0, type=TextObjectType.INCLUSIVE) + + @text_object("i", "w", no_move_handler=True) + def _inner_word(event: E) -> TextObject: + """ + Inner 'word': ciw and diw + """ + start, end = event.current_buffer.document.find_boundaries_of_current_word() + return TextObject(start, end) + + @text_object("a", "w", no_move_handler=True) + def _a_word(event: E) -> TextObject: + """ + A 'word': caw and daw + """ + start, end = event.current_buffer.document.find_boundaries_of_current_word( + include_trailing_whitespace=True + ) + return TextObject(start, end) + + @text_object("i", "W", no_move_handler=True) + def _inner_WORD(event: E) -> TextObject: + """ + Inner 'WORD': ciW and diW + """ + start, end = event.current_buffer.document.find_boundaries_of_current_word( + WORD=True + ) + return TextObject(start, end) + + @text_object("a", "W", no_move_handler=True) + def _a_WORD(event: E) -> TextObject: + """ + A 'WORD': caw and daw + """ + start, end = event.current_buffer.document.find_boundaries_of_current_word( + WORD=True, include_trailing_whitespace=True + ) + return TextObject(start, end) + + @text_object("a", "p", no_move_handler=True) + def _paragraph(event: E) -> TextObject: + """ + Auto paragraph. + """ + start = event.current_buffer.document.start_of_paragraph() + end = event.current_buffer.document.end_of_paragraph(count=event.arg) + return TextObject(start, end) + + @text_object("^") + def _start_of_line(event: E) -> TextObject: + """'c^', 'd^' and '^': Soft start of line, after whitespace.""" + return TextObject( + event.current_buffer.document.get_start_of_line_position( + after_whitespace=True + ) + ) + + @text_object("0") + def _hard_start_of_line(event: E) -> TextObject: + """ + 'c0', 'd0': Hard start of line, before whitespace. + (The move '0' key is implemented elsewhere, because a '0' could also change the `arg`.) + """ + return TextObject( + event.current_buffer.document.get_start_of_line_position( + after_whitespace=False + ) + ) + + def create_ci_ca_handles( + ci_start: str, ci_end: str, inner: bool, key: str | None = None + ) -> None: + # TODO: 'dat', 'dit', (tags (like xml) + """ + Delete/Change string between this start and stop character. But keep these characters. + This implements all the ci", ci<, ci{, ci(, di", di<, ca", ca<, ... combinations. + """ + + def handler(event: E) -> TextObject: + if ci_start == ci_end: + # Quotes + start = event.current_buffer.document.find_backwards( + ci_start, in_current_line=False + ) + end = event.current_buffer.document.find(ci_end, in_current_line=False) + else: + # Brackets + start = event.current_buffer.document.find_enclosing_bracket_left( + ci_start, ci_end + ) + end = event.current_buffer.document.find_enclosing_bracket_right( + ci_start, ci_end + ) + + if start is not None and end is not None: + offset = 0 if inner else 1 + return TextObject(start + 1 - offset, end + offset) + else: + # Nothing found. + return TextObject(0) + + if key is None: + text_object("ai"[inner], ci_start, no_move_handler=True)(handler) + text_object("ai"[inner], ci_end, no_move_handler=True)(handler) + else: + text_object("ai"[inner], key, no_move_handler=True)(handler) + + for inner in (False, True): + for ci_start, ci_end in [ + ('"', '"'), + ("'", "'"), + ("`", "`"), + ("[", "]"), + ("<", ">"), + ("{", "}"), + ("(", ")"), + ]: + create_ci_ca_handles(ci_start, ci_end, inner) + + create_ci_ca_handles("(", ")", inner, "b") # 'dab', 'dib' + create_ci_ca_handles("{", "}", inner, "B") # 'daB', 'diB' + + @text_object("{") + def _previous_section(event: E) -> TextObject: + """ + Move to previous blank-line separated section. + Implements '{', 'c{', 'd{', 'y{' + """ + index = event.current_buffer.document.start_of_paragraph( + count=event.arg, before=True + ) + return TextObject(index) + + @text_object("}") + def _next_section(event: E) -> TextObject: + """ + Move to next blank-line separated section. + Implements '}', 'c}', 'd}', 'y}' + """ + index = event.current_buffer.document.end_of_paragraph( + count=event.arg, after=True + ) + return TextObject(index) + + @text_object("f", Keys.Any) + def _find_next_occurrence(event: E) -> TextObject: + """ + Go to next occurrence of character. Typing 'fx' will move the + cursor to the next occurrence of character. 'x'. + """ + event.app.vi_state.last_character_find = CharacterFind(event.data, False) + match = event.current_buffer.document.find( + event.data, in_current_line=True, count=event.arg + ) + if match: + return TextObject(match, type=TextObjectType.INCLUSIVE) + else: + return TextObject(0) + + @text_object("F", Keys.Any) + def _find_previous_occurrence(event: E) -> TextObject: + """ + Go to previous occurrence of character. Typing 'Fx' will move the + cursor to the previous occurrence of character. 'x'. + """ + event.app.vi_state.last_character_find = CharacterFind(event.data, True) + return TextObject( + event.current_buffer.document.find_backwards( + event.data, in_current_line=True, count=event.arg + ) + or 0 + ) + + @text_object("t", Keys.Any) + def _t(event: E) -> TextObject: + """ + Move right to the next occurrence of c, then one char backward. + """ + event.app.vi_state.last_character_find = CharacterFind(event.data, False) + match = event.current_buffer.document.find( + event.data, in_current_line=True, count=event.arg + ) + if match: + return TextObject(match - 1, type=TextObjectType.INCLUSIVE) + else: + return TextObject(0) + + @text_object("T", Keys.Any) + def _T(event: E) -> TextObject: + """ + Move left to the previous occurrence of c, then one char forward. + """ + event.app.vi_state.last_character_find = CharacterFind(event.data, True) + match = event.current_buffer.document.find_backwards( + event.data, in_current_line=True, count=event.arg + ) + return TextObject(match + 1 if match else 0) + + def repeat(reverse: bool) -> None: + """ + Create ',' and ';' commands. + """ + + @text_object("," if reverse else ";") + def _(event: E) -> TextObject: + """ + Repeat the last 'f'/'F'/'t'/'T' command. + """ + pos: int | None = 0 + vi_state = event.app.vi_state + + type = TextObjectType.EXCLUSIVE + + if vi_state.last_character_find: + char = vi_state.last_character_find.character + backwards = vi_state.last_character_find.backwards + + if reverse: + backwards = not backwards + + if backwards: + pos = event.current_buffer.document.find_backwards( + char, in_current_line=True, count=event.arg + ) + else: + pos = event.current_buffer.document.find( + char, in_current_line=True, count=event.arg + ) + type = TextObjectType.INCLUSIVE + if pos: + return TextObject(pos, type=type) + else: + return TextObject(0) + + repeat(True) + repeat(False) + + @text_object("h") + @text_object("left") + def _left(event: E) -> TextObject: + """ + Implements 'ch', 'dh', 'h': Cursor left. + """ + return TextObject( + event.current_buffer.document.get_cursor_left_position(count=event.arg) + ) + + @text_object("j", no_move_handler=True, no_selection_handler=True) + # Note: We also need `no_selection_handler`, because we in + # selection mode, we prefer the other 'j' binding that keeps + # `buffer.preferred_column`. + def _down(event: E) -> TextObject: + """ + Implements 'cj', 'dj', 'j', ... Cursor up. + """ + return TextObject( + event.current_buffer.document.get_cursor_down_position(count=event.arg), + type=TextObjectType.LINEWISE, + ) + + @text_object("k", no_move_handler=True, no_selection_handler=True) + def _up(event: E) -> TextObject: + """ + Implements 'ck', 'dk', 'k', ... Cursor up. + """ + return TextObject( + event.current_buffer.document.get_cursor_up_position(count=event.arg), + type=TextObjectType.LINEWISE, + ) + + @text_object("l") + @text_object(" ") + @text_object("right") + def _right(event: E) -> TextObject: + """ + Implements 'cl', 'dl', 'l', 'c ', 'd ', ' '. Cursor right. + """ + return TextObject( + event.current_buffer.document.get_cursor_right_position(count=event.arg) + ) + + @text_object("H") + def _top_of_screen(event: E) -> TextObject: + """ + Moves to the start of the visible region. (Below the scroll offset.) + Implements 'cH', 'dH', 'H'. + """ + w = event.app.layout.current_window + b = event.current_buffer + + if w and w.render_info: + # When we find a Window that has BufferControl showing this window, + # move to the start of the visible area. + pos = ( + b.document.translate_row_col_to_index( + w.render_info.first_visible_line(after_scroll_offset=True), 0 + ) + - b.cursor_position + ) + + else: + # Otherwise, move to the start of the input. + pos = -len(b.document.text_before_cursor) + return TextObject(pos, type=TextObjectType.LINEWISE) + + @text_object("M") + def _middle_of_screen(event: E) -> TextObject: + """ + Moves cursor to the vertical center of the visible region. + Implements 'cM', 'dM', 'M'. + """ + w = event.app.layout.current_window + b = event.current_buffer + + if w and w.render_info: + # When we find a Window that has BufferControl showing this window, + # move to the center of the visible area. + pos = ( + b.document.translate_row_col_to_index( + w.render_info.center_visible_line(), 0 + ) + - b.cursor_position + ) + + else: + # Otherwise, move to the start of the input. + pos = -len(b.document.text_before_cursor) + return TextObject(pos, type=TextObjectType.LINEWISE) + + @text_object("L") + def _end_of_screen(event: E) -> TextObject: + """ + Moves to the end of the visible region. (Above the scroll offset.) + """ + w = event.app.layout.current_window + b = event.current_buffer + + if w and w.render_info: + # When we find a Window that has BufferControl showing this window, + # move to the end of the visible area. + pos = ( + b.document.translate_row_col_to_index( + w.render_info.last_visible_line(before_scroll_offset=True), 0 + ) + - b.cursor_position + ) + + else: + # Otherwise, move to the end of the input. + pos = len(b.document.text_after_cursor) + return TextObject(pos, type=TextObjectType.LINEWISE) + + @text_object("n", no_move_handler=True) + def _search_next(event: E) -> TextObject: + """ + Search next. + """ + buff = event.current_buffer + search_state = event.app.current_search_state + + cursor_position = buff.get_search_position( + search_state, include_current_position=False, count=event.arg + ) + return TextObject(cursor_position - buff.cursor_position) + + @handle("n", filter=vi_navigation_mode) + def _search_next2(event: E) -> None: + """ + Search next in navigation mode. (This goes through the history.) + """ + search_state = event.app.current_search_state + + event.current_buffer.apply_search( + search_state, include_current_position=False, count=event.arg + ) + + @text_object("N", no_move_handler=True) + def _search_previous(event: E) -> TextObject: + """ + Search previous. + """ + buff = event.current_buffer + search_state = event.app.current_search_state + + cursor_position = buff.get_search_position( + ~search_state, include_current_position=False, count=event.arg + ) + return TextObject(cursor_position - buff.cursor_position) + + @handle("N", filter=vi_navigation_mode) + def _search_previous2(event: E) -> None: + """ + Search previous in navigation mode. (This goes through the history.) + """ + search_state = event.app.current_search_state + + event.current_buffer.apply_search( + ~search_state, include_current_position=False, count=event.arg + ) + + @handle("z", "+", filter=vi_navigation_mode | vi_selection_mode) + @handle("z", "t", filter=vi_navigation_mode | vi_selection_mode) + @handle("z", "enter", filter=vi_navigation_mode | vi_selection_mode) + def _scroll_top(event: E) -> None: + """ + Scrolls the window to makes the current line the first line in the visible region. + """ + b = event.current_buffer + event.app.layout.current_window.vertical_scroll = b.document.cursor_position_row + + @handle("z", "-", filter=vi_navigation_mode | vi_selection_mode) + @handle("z", "b", filter=vi_navigation_mode | vi_selection_mode) + def _scroll_bottom(event: E) -> None: + """ + Scrolls the window to makes the current line the last line in the visible region. + """ + # We can safely set the scroll offset to zero; the Window will make + # sure that it scrolls at least enough to make the cursor visible + # again. + event.app.layout.current_window.vertical_scroll = 0 + + @handle("z", "z", filter=vi_navigation_mode | vi_selection_mode) + def _scroll_center(event: E) -> None: + """ + Center Window vertically around cursor. + """ + w = event.app.layout.current_window + b = event.current_buffer + + if w and w.render_info: + info = w.render_info + + # Calculate the offset that we need in order to position the row + # containing the cursor in the center. + scroll_height = info.window_height // 2 + + y = max(0, b.document.cursor_position_row - 1) + height = 0 + while y > 0: + line_height = info.get_height_for_line(y) + + if height + line_height < scroll_height: + height += line_height + y -= 1 + else: + break + + w.vertical_scroll = y + + @text_object("%") + def _goto_corresponding_bracket(event: E) -> TextObject: + """ + Implements 'c%', 'd%', '%, 'y%' (Move to corresponding bracket.) + If an 'arg' has been given, go this this % position in the file. + """ + buffer = event.current_buffer + + if event._arg: + # If 'arg' has been given, the meaning of % is to go to the 'x%' + # row in the file. + if 0 < event.arg <= 100: + absolute_index = buffer.document.translate_row_col_to_index( + int((event.arg * buffer.document.line_count - 1) / 100), 0 + ) + return TextObject( + absolute_index - buffer.document.cursor_position, + type=TextObjectType.LINEWISE, + ) + else: + return TextObject(0) # Do nothing. + + else: + # Move to the corresponding opening/closing bracket (()'s, []'s and {}'s). + match = buffer.document.find_matching_bracket_position() + if match: + return TextObject(match, type=TextObjectType.INCLUSIVE) + else: + return TextObject(0) + + @text_object("|") + def _to_column(event: E) -> TextObject: + """ + Move to the n-th column (you may specify the argument n by typing it on + number keys, for example, 20|). + """ + return TextObject( + event.current_buffer.document.get_column_cursor_position(event.arg - 1) + ) + + @text_object("g", "g") + def _goto_first_line(event: E) -> TextObject: + """ + Go to the start of the very first line. + Implements 'gg', 'cgg', 'ygg' + """ + d = event.current_buffer.document + + if event._arg: + # Move to the given line. + return TextObject( + d.translate_row_col_to_index(event.arg - 1, 0) - d.cursor_position, + type=TextObjectType.LINEWISE, + ) + else: + # Move to the top of the input. + return TextObject( + d.get_start_of_document_position(), type=TextObjectType.LINEWISE + ) + + @text_object("g", "_") + def _goto_last_line(event: E) -> TextObject: + """ + Go to last non-blank of line. + 'g_', 'cg_', 'yg_', etc.. + """ + return TextObject( + event.current_buffer.document.last_non_blank_of_current_line_position(), + type=TextObjectType.INCLUSIVE, + ) + + @text_object("g", "e") + def _ge(event: E) -> TextObject: + """ + Go to last character of previous word. + 'ge', 'cge', 'yge', etc.. + """ + prev_end = event.current_buffer.document.find_previous_word_ending( + count=event.arg + ) + return TextObject( + prev_end - 1 if prev_end is not None else 0, type=TextObjectType.INCLUSIVE + ) + + @text_object("g", "E") + def _gE(event: E) -> TextObject: + """ + Go to last character of previous WORD. + 'gE', 'cgE', 'ygE', etc.. + """ + prev_end = event.current_buffer.document.find_previous_word_ending( + count=event.arg, WORD=True + ) + return TextObject( + prev_end - 1 if prev_end is not None else 0, type=TextObjectType.INCLUSIVE + ) + + @text_object("g", "m") + def _gm(event: E) -> TextObject: + """ + Like g0, but half a screenwidth to the right. (Or as much as possible.) + """ + w = event.app.layout.current_window + buff = event.current_buffer + + if w and w.render_info: + width = w.render_info.window_width + start = buff.document.get_start_of_line_position(after_whitespace=False) + start += int(min(width / 2, len(buff.document.current_line))) + + return TextObject(start, type=TextObjectType.INCLUSIVE) + return TextObject(0) + + @text_object("G") + def _last_line(event: E) -> TextObject: + """ + Go to the end of the document. (If no arg has been given.) + """ + buf = event.current_buffer + return TextObject( + buf.document.translate_row_col_to_index(buf.document.line_count - 1, 0) + - buf.cursor_position, + type=TextObjectType.LINEWISE, + ) + + # + # *** Other *** + # + + @handle("G", filter=has_arg) + def _to_nth_history_line(event: E) -> None: + """ + If an argument is given, move to this line in the history. (for + example, 15G) + """ + event.current_buffer.go_to_history(event.arg - 1) + + for n in "123456789": + + @handle( + n, + filter=vi_navigation_mode + | vi_selection_mode + | vi_waiting_for_text_object_mode, + ) + def _arg(event: E) -> None: + """ + Always handle numerics in navigation mode as arg. + """ + event.append_to_arg_count(event.data) + + @handle( + "0", + filter=( + vi_navigation_mode | vi_selection_mode | vi_waiting_for_text_object_mode + ) + & has_arg, + ) + def _0_arg(event: E) -> None: + """ + Zero when an argument was already give. + """ + event.append_to_arg_count(event.data) + + @handle(Keys.Any, filter=vi_replace_mode) + def _insert_text(event: E) -> None: + """ + Insert data at cursor position. + """ + event.current_buffer.insert_text(event.data, overwrite=True) + + @handle(Keys.Any, filter=vi_replace_single_mode) + def _replace_single(event: E) -> None: + """ + Replace single character at cursor position. + """ + event.current_buffer.insert_text(event.data, overwrite=True) + event.current_buffer.cursor_position -= 1 + event.app.vi_state.input_mode = InputMode.NAVIGATION + + @handle( + Keys.Any, + filter=vi_insert_multiple_mode, + save_before=(lambda e: not e.is_repeat), + ) + def _insert_text_multiple_cursors(event: E) -> None: + """ + Insert data at multiple cursor positions at once. + (Usually a result of pressing 'I' or 'A' in block-selection mode.) + """ + buff = event.current_buffer + original_text = buff.text + + # Construct new text. + text = [] + p = 0 + + for p2 in buff.multiple_cursor_positions: + text.append(original_text[p:p2]) + text.append(event.data) + p = p2 + + text.append(original_text[p:]) + + # Shift all cursor positions. + new_cursor_positions = [ + pos + i + 1 for i, pos in enumerate(buff.multiple_cursor_positions) + ] + + # Set result. + buff.text = "".join(text) + buff.multiple_cursor_positions = new_cursor_positions + buff.cursor_position += 1 + + @handle("backspace", filter=vi_insert_multiple_mode) + def _delete_before_multiple_cursors(event: E) -> None: + """ + Backspace, using multiple cursors. + """ + buff = event.current_buffer + original_text = buff.text + + # Construct new text. + deleted_something = False + text = [] + p = 0 + + for p2 in buff.multiple_cursor_positions: + if p2 > 0 and original_text[p2 - 1] != "\n": # Don't delete across lines. + text.append(original_text[p : p2 - 1]) + deleted_something = True + else: + text.append(original_text[p:p2]) + p = p2 + + text.append(original_text[p:]) + + if deleted_something: + # Shift all cursor positions. + lengths = [len(part) for part in text[:-1]] + new_cursor_positions = list(accumulate(lengths)) + + # Set result. + buff.text = "".join(text) + buff.multiple_cursor_positions = new_cursor_positions + buff.cursor_position -= 1 + else: + event.app.output.bell() + + @handle("delete", filter=vi_insert_multiple_mode) + def _delete_after_multiple_cursors(event: E) -> None: + """ + Delete, using multiple cursors. + """ + buff = event.current_buffer + original_text = buff.text + + # Construct new text. + deleted_something = False + text = [] + new_cursor_positions = [] + p = 0 + + for p2 in buff.multiple_cursor_positions: + text.append(original_text[p:p2]) + if p2 >= len(original_text) or original_text[p2] == "\n": + # Don't delete across lines. + p = p2 + else: + p = p2 + 1 + deleted_something = True + + text.append(original_text[p:]) + + if deleted_something: + # Shift all cursor positions. + lengths = [len(part) for part in text[:-1]] + new_cursor_positions = list(accumulate(lengths)) + + # Set result. + buff.text = "".join(text) + buff.multiple_cursor_positions = new_cursor_positions + else: + event.app.output.bell() + + @handle("left", filter=vi_insert_multiple_mode) + def _left_multiple(event: E) -> None: + """ + Move all cursors to the left. + (But keep all cursors on the same line.) + """ + buff = event.current_buffer + new_positions = [] + + for p in buff.multiple_cursor_positions: + if buff.document.translate_index_to_position(p)[1] > 0: + p -= 1 + new_positions.append(p) + + buff.multiple_cursor_positions = new_positions + + if buff.document.cursor_position_col > 0: + buff.cursor_position -= 1 + + @handle("right", filter=vi_insert_multiple_mode) + def _right_multiple(event: E) -> None: + """ + Move all cursors to the right. + (But keep all cursors on the same line.) + """ + buff = event.current_buffer + new_positions = [] + + for p in buff.multiple_cursor_positions: + row, column = buff.document.translate_index_to_position(p) + if column < len(buff.document.lines[row]): + p += 1 + new_positions.append(p) + + buff.multiple_cursor_positions = new_positions + + if not buff.document.is_cursor_at_the_end_of_line: + buff.cursor_position += 1 + + @handle("up", filter=vi_insert_multiple_mode) + @handle("down", filter=vi_insert_multiple_mode) + def _updown_multiple(event: E) -> None: + """ + Ignore all up/down key presses when in multiple cursor mode. + """ + + @handle("c-x", "c-l", filter=vi_insert_mode) + def _complete_line(event: E) -> None: + """ + Pressing the ControlX - ControlL sequence in Vi mode does line + completion based on the other lines in the document and the history. + """ + event.current_buffer.start_history_lines_completion() + + @handle("c-x", "c-f", filter=vi_insert_mode) + def _complete_filename(event: E) -> None: + """ + Complete file names. + """ + # TODO + pass + + @handle("c-k", filter=vi_insert_mode | vi_replace_mode) + def _digraph(event: E) -> None: + """ + Go into digraph mode. + """ + event.app.vi_state.waiting_for_digraph = True + + @Condition + def digraph_symbol_1_given() -> bool: + return get_app().vi_state.digraph_symbol1 is not None + + @handle(Keys.Any, filter=vi_digraph_mode & ~digraph_symbol_1_given) + def _digraph1(event: E) -> None: + """ + First digraph symbol. + """ + event.app.vi_state.digraph_symbol1 = event.data + + @handle(Keys.Any, filter=vi_digraph_mode & digraph_symbol_1_given) + def _create_digraph(event: E) -> None: + """ + Insert digraph. + """ + try: + # Lookup. + code: tuple[str, str] = ( + event.app.vi_state.digraph_symbol1 or "", + event.data, + ) + if code not in DIGRAPHS: + code = code[::-1] # Try reversing. + symbol = DIGRAPHS[code] + except KeyError: + # Unknown digraph. + event.app.output.bell() + else: + # Insert digraph. + overwrite = event.app.vi_state.input_mode == InputMode.REPLACE + event.current_buffer.insert_text(chr(symbol), overwrite=overwrite) + event.app.vi_state.waiting_for_digraph = False + finally: + event.app.vi_state.waiting_for_digraph = False + event.app.vi_state.digraph_symbol1 = None + + @handle("c-o", filter=vi_insert_mode | vi_replace_mode) + def _quick_normal_mode(event: E) -> None: + """ + Go into normal mode for one single action. + """ + event.app.vi_state.temporary_navigation_mode = True + + @handle("q", Keys.Any, filter=vi_navigation_mode & ~vi_recording_macro) + def _start_macro(event: E) -> None: + """ + Start recording macro. + """ + c = event.key_sequence[1].data + if c in vi_register_names: + vi_state = event.app.vi_state + + vi_state.recording_register = c + vi_state.current_recording = "" + + @handle("q", filter=vi_navigation_mode & vi_recording_macro) + def _stop_macro(event: E) -> None: + """ + Stop recording macro. + """ + vi_state = event.app.vi_state + + # Store and stop recording. + if vi_state.recording_register: + vi_state.named_registers[vi_state.recording_register] = ClipboardData( + vi_state.current_recording + ) + vi_state.recording_register = None + vi_state.current_recording = "" + + @handle("@", Keys.Any, filter=vi_navigation_mode, record_in_macro=False) + def _execute_macro(event: E) -> None: + """ + Execute macro. + + Notice that we pass `record_in_macro=False`. This ensures that the `@x` + keys don't appear in the recording itself. This function inserts the + body of the called macro back into the KeyProcessor, so these keys will + be added later on to the macro of their handlers have + `record_in_macro=True`. + """ + # Retrieve macro. + c = event.key_sequence[1].data + try: + macro = event.app.vi_state.named_registers[c] + except KeyError: + return + + # Expand macro (which is a string in the register), in individual keys. + # Use vt100 parser for this. + keys: list[KeyPress] = [] + + parser = Vt100Parser(keys.append) + parser.feed(macro.text) + parser.flush() + + # Now feed keys back to the input processor. + for _ in range(event.arg): + event.app.key_processor.feed_multiple(keys, first=True) + + return ConditionalKeyBindings(key_bindings, vi_mode) + + +def load_vi_search_bindings() -> KeyBindingsBase: + key_bindings = KeyBindings() + handle = key_bindings.add + from . import search + + @Condition + def search_buffer_is_empty() -> bool: + "Returns True when the search buffer is empty." + return get_app().current_buffer.text == "" + + # Vi-style forward search. + handle( + "/", + filter=(vi_navigation_mode | vi_selection_mode) & ~vi_search_direction_reversed, + )(search.start_forward_incremental_search) + handle( + "?", + filter=(vi_navigation_mode | vi_selection_mode) & vi_search_direction_reversed, + )(search.start_forward_incremental_search) + handle("c-s")(search.start_forward_incremental_search) + + # Vi-style backward search. + handle( + "?", + filter=(vi_navigation_mode | vi_selection_mode) & ~vi_search_direction_reversed, + )(search.start_reverse_incremental_search) + handle( + "/", + filter=(vi_navigation_mode | vi_selection_mode) & vi_search_direction_reversed, + )(search.start_reverse_incremental_search) + handle("c-r")(search.start_reverse_incremental_search) + + # Apply the search. (At the / or ? prompt.) + handle("enter", filter=is_searching)(search.accept_search) + + handle("c-r", filter=is_searching)(search.reverse_incremental_search) + handle("c-s", filter=is_searching)(search.forward_incremental_search) + + handle("c-c")(search.abort_search) + handle("c-g")(search.abort_search) + handle("backspace", filter=search_buffer_is_empty)(search.abort_search) + + # Handle escape. This should accept the search, just like readline. + # `abort_search` would be a meaningful alternative. + handle("escape")(search.accept_search) + + return ConditionalKeyBindings(key_bindings, vi_mode) diff --git a/src/prompt_toolkit/key_binding/defaults.py b/src/prompt_toolkit/key_binding/defaults.py new file mode 100644 index 0000000..166da8d --- /dev/null +++ b/src/prompt_toolkit/key_binding/defaults.py @@ -0,0 +1,62 @@ +""" +Default key bindings.:: + + key_bindings = load_key_bindings() + app = Application(key_bindings=key_bindings) +""" +from __future__ import annotations + +from prompt_toolkit.filters import buffer_has_focus +from prompt_toolkit.key_binding.bindings.basic import load_basic_bindings +from prompt_toolkit.key_binding.bindings.cpr import load_cpr_bindings +from prompt_toolkit.key_binding.bindings.emacs import ( + load_emacs_bindings, + load_emacs_search_bindings, + load_emacs_shift_selection_bindings, +) +from prompt_toolkit.key_binding.bindings.mouse import load_mouse_bindings +from prompt_toolkit.key_binding.bindings.vi import ( + load_vi_bindings, + load_vi_search_bindings, +) +from prompt_toolkit.key_binding.key_bindings import ( + ConditionalKeyBindings, + KeyBindingsBase, + merge_key_bindings, +) + +__all__ = [ + "load_key_bindings", +] + + +def load_key_bindings() -> KeyBindingsBase: + """ + Create a KeyBindings object that contains the default key bindings. + """ + all_bindings = merge_key_bindings( + [ + # Load basic bindings. + load_basic_bindings(), + # Load emacs bindings. + load_emacs_bindings(), + load_emacs_search_bindings(), + load_emacs_shift_selection_bindings(), + # Load Vi bindings. + load_vi_bindings(), + load_vi_search_bindings(), + ] + ) + + return merge_key_bindings( + [ + # Make sure that the above key bindings are only active if the + # currently focused control is a `BufferControl`. For other controls, we + # don't want these key bindings to intervene. (This would break "ptterm" + # for instance, which handles 'Keys.Any' in the user control itself.) + ConditionalKeyBindings(all_bindings, buffer_has_focus), + # Active, even when no buffer has been focused. + load_mouse_bindings(), + load_cpr_bindings(), + ] + ) diff --git a/src/prompt_toolkit/key_binding/digraphs.py b/src/prompt_toolkit/key_binding/digraphs.py new file mode 100644 index 0000000..1e8a432 --- /dev/null +++ b/src/prompt_toolkit/key_binding/digraphs.py @@ -0,0 +1,1377 @@ +""" +Vi Digraphs. +This is a list of special characters that can be inserted in Vi insert mode by +pressing Control-K followed by to normal characters. + +Taken from Neovim and translated to Python: +https://raw.githubusercontent.com/neovim/neovim/master/src/nvim/digraph.c +""" +from __future__ import annotations + +__all__ = [ + "DIGRAPHS", +] + +# digraphs for Unicode from RFC1345 +# (also work for ISO-8859-1 aka latin1) +DIGRAPHS: dict[tuple[str, str], int] = { + ("N", "U"): 0x00, + ("S", "H"): 0x01, + ("S", "X"): 0x02, + ("E", "X"): 0x03, + ("E", "T"): 0x04, + ("E", "Q"): 0x05, + ("A", "K"): 0x06, + ("B", "L"): 0x07, + ("B", "S"): 0x08, + ("H", "T"): 0x09, + ("L", "F"): 0x0A, + ("V", "T"): 0x0B, + ("F", "F"): 0x0C, + ("C", "R"): 0x0D, + ("S", "O"): 0x0E, + ("S", "I"): 0x0F, + ("D", "L"): 0x10, + ("D", "1"): 0x11, + ("D", "2"): 0x12, + ("D", "3"): 0x13, + ("D", "4"): 0x14, + ("N", "K"): 0x15, + ("S", "Y"): 0x16, + ("E", "B"): 0x17, + ("C", "N"): 0x18, + ("E", "M"): 0x19, + ("S", "B"): 0x1A, + ("E", "C"): 0x1B, + ("F", "S"): 0x1C, + ("G", "S"): 0x1D, + ("R", "S"): 0x1E, + ("U", "S"): 0x1F, + ("S", "P"): 0x20, + ("N", "b"): 0x23, + ("D", "O"): 0x24, + ("A", "t"): 0x40, + ("<", "("): 0x5B, + ("/", "/"): 0x5C, + (")", ">"): 0x5D, + ("'", ">"): 0x5E, + ("'", "!"): 0x60, + ("(", "!"): 0x7B, + ("!", "!"): 0x7C, + ("!", ")"): 0x7D, + ("'", "?"): 0x7E, + ("D", "T"): 0x7F, + ("P", "A"): 0x80, + ("H", "O"): 0x81, + ("B", "H"): 0x82, + ("N", "H"): 0x83, + ("I", "N"): 0x84, + ("N", "L"): 0x85, + ("S", "A"): 0x86, + ("E", "S"): 0x87, + ("H", "S"): 0x88, + ("H", "J"): 0x89, + ("V", "S"): 0x8A, + ("P", "D"): 0x8B, + ("P", "U"): 0x8C, + ("R", "I"): 0x8D, + ("S", "2"): 0x8E, + ("S", "3"): 0x8F, + ("D", "C"): 0x90, + ("P", "1"): 0x91, + ("P", "2"): 0x92, + ("T", "S"): 0x93, + ("C", "C"): 0x94, + ("M", "W"): 0x95, + ("S", "G"): 0x96, + ("E", "G"): 0x97, + ("S", "S"): 0x98, + ("G", "C"): 0x99, + ("S", "C"): 0x9A, + ("C", "I"): 0x9B, + ("S", "T"): 0x9C, + ("O", "C"): 0x9D, + ("P", "M"): 0x9E, + ("A", "C"): 0x9F, + ("N", "S"): 0xA0, + ("!", "I"): 0xA1, + ("C", "t"): 0xA2, + ("P", "d"): 0xA3, + ("C", "u"): 0xA4, + ("Y", "e"): 0xA5, + ("B", "B"): 0xA6, + ("S", "E"): 0xA7, + ("'", ":"): 0xA8, + ("C", "o"): 0xA9, + ("-", "a"): 0xAA, + ("<", "<"): 0xAB, + ("N", "O"): 0xAC, + ("-", "-"): 0xAD, + ("R", "g"): 0xAE, + ("'", "m"): 0xAF, + ("D", "G"): 0xB0, + ("+", "-"): 0xB1, + ("2", "S"): 0xB2, + ("3", "S"): 0xB3, + ("'", "'"): 0xB4, + ("M", "y"): 0xB5, + ("P", "I"): 0xB6, + (".", "M"): 0xB7, + ("'", ","): 0xB8, + ("1", "S"): 0xB9, + ("-", "o"): 0xBA, + (">", ">"): 0xBB, + ("1", "4"): 0xBC, + ("1", "2"): 0xBD, + ("3", "4"): 0xBE, + ("?", "I"): 0xBF, + ("A", "!"): 0xC0, + ("A", "'"): 0xC1, + ("A", ">"): 0xC2, + ("A", "?"): 0xC3, + ("A", ":"): 0xC4, + ("A", "A"): 0xC5, + ("A", "E"): 0xC6, + ("C", ","): 0xC7, + ("E", "!"): 0xC8, + ("E", "'"): 0xC9, + ("E", ">"): 0xCA, + ("E", ":"): 0xCB, + ("I", "!"): 0xCC, + ("I", "'"): 0xCD, + ("I", ">"): 0xCE, + ("I", ":"): 0xCF, + ("D", "-"): 0xD0, + ("N", "?"): 0xD1, + ("O", "!"): 0xD2, + ("O", "'"): 0xD3, + ("O", ">"): 0xD4, + ("O", "?"): 0xD5, + ("O", ":"): 0xD6, + ("*", "X"): 0xD7, + ("O", "/"): 0xD8, + ("U", "!"): 0xD9, + ("U", "'"): 0xDA, + ("U", ">"): 0xDB, + ("U", ":"): 0xDC, + ("Y", "'"): 0xDD, + ("T", "H"): 0xDE, + ("s", "s"): 0xDF, + ("a", "!"): 0xE0, + ("a", "'"): 0xE1, + ("a", ">"): 0xE2, + ("a", "?"): 0xE3, + ("a", ":"): 0xE4, + ("a", "a"): 0xE5, + ("a", "e"): 0xE6, + ("c", ","): 0xE7, + ("e", "!"): 0xE8, + ("e", "'"): 0xE9, + ("e", ">"): 0xEA, + ("e", ":"): 0xEB, + ("i", "!"): 0xEC, + ("i", "'"): 0xED, + ("i", ">"): 0xEE, + ("i", ":"): 0xEF, + ("d", "-"): 0xF0, + ("n", "?"): 0xF1, + ("o", "!"): 0xF2, + ("o", "'"): 0xF3, + ("o", ">"): 0xF4, + ("o", "?"): 0xF5, + ("o", ":"): 0xF6, + ("-", ":"): 0xF7, + ("o", "/"): 0xF8, + ("u", "!"): 0xF9, + ("u", "'"): 0xFA, + ("u", ">"): 0xFB, + ("u", ":"): 0xFC, + ("y", "'"): 0xFD, + ("t", "h"): 0xFE, + ("y", ":"): 0xFF, + ("A", "-"): 0x0100, + ("a", "-"): 0x0101, + ("A", "("): 0x0102, + ("a", "("): 0x0103, + ("A", ";"): 0x0104, + ("a", ";"): 0x0105, + ("C", "'"): 0x0106, + ("c", "'"): 0x0107, + ("C", ">"): 0x0108, + ("c", ">"): 0x0109, + ("C", "."): 0x010A, + ("c", "."): 0x010B, + ("C", "<"): 0x010C, + ("c", "<"): 0x010D, + ("D", "<"): 0x010E, + ("d", "<"): 0x010F, + ("D", "/"): 0x0110, + ("d", "/"): 0x0111, + ("E", "-"): 0x0112, + ("e", "-"): 0x0113, + ("E", "("): 0x0114, + ("e", "("): 0x0115, + ("E", "."): 0x0116, + ("e", "."): 0x0117, + ("E", ";"): 0x0118, + ("e", ";"): 0x0119, + ("E", "<"): 0x011A, + ("e", "<"): 0x011B, + ("G", ">"): 0x011C, + ("g", ">"): 0x011D, + ("G", "("): 0x011E, + ("g", "("): 0x011F, + ("G", "."): 0x0120, + ("g", "."): 0x0121, + ("G", ","): 0x0122, + ("g", ","): 0x0123, + ("H", ">"): 0x0124, + ("h", ">"): 0x0125, + ("H", "/"): 0x0126, + ("h", "/"): 0x0127, + ("I", "?"): 0x0128, + ("i", "?"): 0x0129, + ("I", "-"): 0x012A, + ("i", "-"): 0x012B, + ("I", "("): 0x012C, + ("i", "("): 0x012D, + ("I", ";"): 0x012E, + ("i", ";"): 0x012F, + ("I", "."): 0x0130, + ("i", "."): 0x0131, + ("I", "J"): 0x0132, + ("i", "j"): 0x0133, + ("J", ">"): 0x0134, + ("j", ">"): 0x0135, + ("K", ","): 0x0136, + ("k", ","): 0x0137, + ("k", "k"): 0x0138, + ("L", "'"): 0x0139, + ("l", "'"): 0x013A, + ("L", ","): 0x013B, + ("l", ","): 0x013C, + ("L", "<"): 0x013D, + ("l", "<"): 0x013E, + ("L", "."): 0x013F, + ("l", "."): 0x0140, + ("L", "/"): 0x0141, + ("l", "/"): 0x0142, + ("N", "'"): 0x0143, + ("n", "'"): 0x0144, + ("N", ","): 0x0145, + ("n", ","): 0x0146, + ("N", "<"): 0x0147, + ("n", "<"): 0x0148, + ("'", "n"): 0x0149, + ("N", "G"): 0x014A, + ("n", "g"): 0x014B, + ("O", "-"): 0x014C, + ("o", "-"): 0x014D, + ("O", "("): 0x014E, + ("o", "("): 0x014F, + ("O", '"'): 0x0150, + ("o", '"'): 0x0151, + ("O", "E"): 0x0152, + ("o", "e"): 0x0153, + ("R", "'"): 0x0154, + ("r", "'"): 0x0155, + ("R", ","): 0x0156, + ("r", ","): 0x0157, + ("R", "<"): 0x0158, + ("r", "<"): 0x0159, + ("S", "'"): 0x015A, + ("s", "'"): 0x015B, + ("S", ">"): 0x015C, + ("s", ">"): 0x015D, + ("S", ","): 0x015E, + ("s", ","): 0x015F, + ("S", "<"): 0x0160, + ("s", "<"): 0x0161, + ("T", ","): 0x0162, + ("t", ","): 0x0163, + ("T", "<"): 0x0164, + ("t", "<"): 0x0165, + ("T", "/"): 0x0166, + ("t", "/"): 0x0167, + ("U", "?"): 0x0168, + ("u", "?"): 0x0169, + ("U", "-"): 0x016A, + ("u", "-"): 0x016B, + ("U", "("): 0x016C, + ("u", "("): 0x016D, + ("U", "0"): 0x016E, + ("u", "0"): 0x016F, + ("U", '"'): 0x0170, + ("u", '"'): 0x0171, + ("U", ";"): 0x0172, + ("u", ";"): 0x0173, + ("W", ">"): 0x0174, + ("w", ">"): 0x0175, + ("Y", ">"): 0x0176, + ("y", ">"): 0x0177, + ("Y", ":"): 0x0178, + ("Z", "'"): 0x0179, + ("z", "'"): 0x017A, + ("Z", "."): 0x017B, + ("z", "."): 0x017C, + ("Z", "<"): 0x017D, + ("z", "<"): 0x017E, + ("O", "9"): 0x01A0, + ("o", "9"): 0x01A1, + ("O", "I"): 0x01A2, + ("o", "i"): 0x01A3, + ("y", "r"): 0x01A6, + ("U", "9"): 0x01AF, + ("u", "9"): 0x01B0, + ("Z", "/"): 0x01B5, + ("z", "/"): 0x01B6, + ("E", "D"): 0x01B7, + ("A", "<"): 0x01CD, + ("a", "<"): 0x01CE, + ("I", "<"): 0x01CF, + ("i", "<"): 0x01D0, + ("O", "<"): 0x01D1, + ("o", "<"): 0x01D2, + ("U", "<"): 0x01D3, + ("u", "<"): 0x01D4, + ("A", "1"): 0x01DE, + ("a", "1"): 0x01DF, + ("A", "7"): 0x01E0, + ("a", "7"): 0x01E1, + ("A", "3"): 0x01E2, + ("a", "3"): 0x01E3, + ("G", "/"): 0x01E4, + ("g", "/"): 0x01E5, + ("G", "<"): 0x01E6, + ("g", "<"): 0x01E7, + ("K", "<"): 0x01E8, + ("k", "<"): 0x01E9, + ("O", ";"): 0x01EA, + ("o", ";"): 0x01EB, + ("O", "1"): 0x01EC, + ("o", "1"): 0x01ED, + ("E", "Z"): 0x01EE, + ("e", "z"): 0x01EF, + ("j", "<"): 0x01F0, + ("G", "'"): 0x01F4, + ("g", "'"): 0x01F5, + (";", "S"): 0x02BF, + ("'", "<"): 0x02C7, + ("'", "("): 0x02D8, + ("'", "."): 0x02D9, + ("'", "0"): 0x02DA, + ("'", ";"): 0x02DB, + ("'", '"'): 0x02DD, + ("A", "%"): 0x0386, + ("E", "%"): 0x0388, + ("Y", "%"): 0x0389, + ("I", "%"): 0x038A, + ("O", "%"): 0x038C, + ("U", "%"): 0x038E, + ("W", "%"): 0x038F, + ("i", "3"): 0x0390, + ("A", "*"): 0x0391, + ("B", "*"): 0x0392, + ("G", "*"): 0x0393, + ("D", "*"): 0x0394, + ("E", "*"): 0x0395, + ("Z", "*"): 0x0396, + ("Y", "*"): 0x0397, + ("H", "*"): 0x0398, + ("I", "*"): 0x0399, + ("K", "*"): 0x039A, + ("L", "*"): 0x039B, + ("M", "*"): 0x039C, + ("N", "*"): 0x039D, + ("C", "*"): 0x039E, + ("O", "*"): 0x039F, + ("P", "*"): 0x03A0, + ("R", "*"): 0x03A1, + ("S", "*"): 0x03A3, + ("T", "*"): 0x03A4, + ("U", "*"): 0x03A5, + ("F", "*"): 0x03A6, + ("X", "*"): 0x03A7, + ("Q", "*"): 0x03A8, + ("W", "*"): 0x03A9, + ("J", "*"): 0x03AA, + ("V", "*"): 0x03AB, + ("a", "%"): 0x03AC, + ("e", "%"): 0x03AD, + ("y", "%"): 0x03AE, + ("i", "%"): 0x03AF, + ("u", "3"): 0x03B0, + ("a", "*"): 0x03B1, + ("b", "*"): 0x03B2, + ("g", "*"): 0x03B3, + ("d", "*"): 0x03B4, + ("e", "*"): 0x03B5, + ("z", "*"): 0x03B6, + ("y", "*"): 0x03B7, + ("h", "*"): 0x03B8, + ("i", "*"): 0x03B9, + ("k", "*"): 0x03BA, + ("l", "*"): 0x03BB, + ("m", "*"): 0x03BC, + ("n", "*"): 0x03BD, + ("c", "*"): 0x03BE, + ("o", "*"): 0x03BF, + ("p", "*"): 0x03C0, + ("r", "*"): 0x03C1, + ("*", "s"): 0x03C2, + ("s", "*"): 0x03C3, + ("t", "*"): 0x03C4, + ("u", "*"): 0x03C5, + ("f", "*"): 0x03C6, + ("x", "*"): 0x03C7, + ("q", "*"): 0x03C8, + ("w", "*"): 0x03C9, + ("j", "*"): 0x03CA, + ("v", "*"): 0x03CB, + ("o", "%"): 0x03CC, + ("u", "%"): 0x03CD, + ("w", "%"): 0x03CE, + ("'", "G"): 0x03D8, + (",", "G"): 0x03D9, + ("T", "3"): 0x03DA, + ("t", "3"): 0x03DB, + ("M", "3"): 0x03DC, + ("m", "3"): 0x03DD, + ("K", "3"): 0x03DE, + ("k", "3"): 0x03DF, + ("P", "3"): 0x03E0, + ("p", "3"): 0x03E1, + ("'", "%"): 0x03F4, + ("j", "3"): 0x03F5, + ("I", "O"): 0x0401, + ("D", "%"): 0x0402, + ("G", "%"): 0x0403, + ("I", "E"): 0x0404, + ("D", "S"): 0x0405, + ("I", "I"): 0x0406, + ("Y", "I"): 0x0407, + ("J", "%"): 0x0408, + ("L", "J"): 0x0409, + ("N", "J"): 0x040A, + ("T", "s"): 0x040B, + ("K", "J"): 0x040C, + ("V", "%"): 0x040E, + ("D", "Z"): 0x040F, + ("A", "="): 0x0410, + ("B", "="): 0x0411, + ("V", "="): 0x0412, + ("G", "="): 0x0413, + ("D", "="): 0x0414, + ("E", "="): 0x0415, + ("Z", "%"): 0x0416, + ("Z", "="): 0x0417, + ("I", "="): 0x0418, + ("J", "="): 0x0419, + ("K", "="): 0x041A, + ("L", "="): 0x041B, + ("M", "="): 0x041C, + ("N", "="): 0x041D, + ("O", "="): 0x041E, + ("P", "="): 0x041F, + ("R", "="): 0x0420, + ("S", "="): 0x0421, + ("T", "="): 0x0422, + ("U", "="): 0x0423, + ("F", "="): 0x0424, + ("H", "="): 0x0425, + ("C", "="): 0x0426, + ("C", "%"): 0x0427, + ("S", "%"): 0x0428, + ("S", "c"): 0x0429, + ("=", '"'): 0x042A, + ("Y", "="): 0x042B, + ("%", '"'): 0x042C, + ("J", "E"): 0x042D, + ("J", "U"): 0x042E, + ("J", "A"): 0x042F, + ("a", "="): 0x0430, + ("b", "="): 0x0431, + ("v", "="): 0x0432, + ("g", "="): 0x0433, + ("d", "="): 0x0434, + ("e", "="): 0x0435, + ("z", "%"): 0x0436, + ("z", "="): 0x0437, + ("i", "="): 0x0438, + ("j", "="): 0x0439, + ("k", "="): 0x043A, + ("l", "="): 0x043B, + ("m", "="): 0x043C, + ("n", "="): 0x043D, + ("o", "="): 0x043E, + ("p", "="): 0x043F, + ("r", "="): 0x0440, + ("s", "="): 0x0441, + ("t", "="): 0x0442, + ("u", "="): 0x0443, + ("f", "="): 0x0444, + ("h", "="): 0x0445, + ("c", "="): 0x0446, + ("c", "%"): 0x0447, + ("s", "%"): 0x0448, + ("s", "c"): 0x0449, + ("=", "'"): 0x044A, + ("y", "="): 0x044B, + ("%", "'"): 0x044C, + ("j", "e"): 0x044D, + ("j", "u"): 0x044E, + ("j", "a"): 0x044F, + ("i", "o"): 0x0451, + ("d", "%"): 0x0452, + ("g", "%"): 0x0453, + ("i", "e"): 0x0454, + ("d", "s"): 0x0455, + ("i", "i"): 0x0456, + ("y", "i"): 0x0457, + ("j", "%"): 0x0458, + ("l", "j"): 0x0459, + ("n", "j"): 0x045A, + ("t", "s"): 0x045B, + ("k", "j"): 0x045C, + ("v", "%"): 0x045E, + ("d", "z"): 0x045F, + ("Y", "3"): 0x0462, + ("y", "3"): 0x0463, + ("O", "3"): 0x046A, + ("o", "3"): 0x046B, + ("F", "3"): 0x0472, + ("f", "3"): 0x0473, + ("V", "3"): 0x0474, + ("v", "3"): 0x0475, + ("C", "3"): 0x0480, + ("c", "3"): 0x0481, + ("G", "3"): 0x0490, + ("g", "3"): 0x0491, + ("A", "+"): 0x05D0, + ("B", "+"): 0x05D1, + ("G", "+"): 0x05D2, + ("D", "+"): 0x05D3, + ("H", "+"): 0x05D4, + ("W", "+"): 0x05D5, + ("Z", "+"): 0x05D6, + ("X", "+"): 0x05D7, + ("T", "j"): 0x05D8, + ("J", "+"): 0x05D9, + ("K", "%"): 0x05DA, + ("K", "+"): 0x05DB, + ("L", "+"): 0x05DC, + ("M", "%"): 0x05DD, + ("M", "+"): 0x05DE, + ("N", "%"): 0x05DF, + ("N", "+"): 0x05E0, + ("S", "+"): 0x05E1, + ("E", "+"): 0x05E2, + ("P", "%"): 0x05E3, + ("P", "+"): 0x05E4, + ("Z", "j"): 0x05E5, + ("Z", "J"): 0x05E6, + ("Q", "+"): 0x05E7, + ("R", "+"): 0x05E8, + ("S", "h"): 0x05E9, + ("T", "+"): 0x05EA, + (",", "+"): 0x060C, + (";", "+"): 0x061B, + ("?", "+"): 0x061F, + ("H", "'"): 0x0621, + ("a", "M"): 0x0622, + ("a", "H"): 0x0623, + ("w", "H"): 0x0624, + ("a", "h"): 0x0625, + ("y", "H"): 0x0626, + ("a", "+"): 0x0627, + ("b", "+"): 0x0628, + ("t", "m"): 0x0629, + ("t", "+"): 0x062A, + ("t", "k"): 0x062B, + ("g", "+"): 0x062C, + ("h", "k"): 0x062D, + ("x", "+"): 0x062E, + ("d", "+"): 0x062F, + ("d", "k"): 0x0630, + ("r", "+"): 0x0631, + ("z", "+"): 0x0632, + ("s", "+"): 0x0633, + ("s", "n"): 0x0634, + ("c", "+"): 0x0635, + ("d", "d"): 0x0636, + ("t", "j"): 0x0637, + ("z", "H"): 0x0638, + ("e", "+"): 0x0639, + ("i", "+"): 0x063A, + ("+", "+"): 0x0640, + ("f", "+"): 0x0641, + ("q", "+"): 0x0642, + ("k", "+"): 0x0643, + ("l", "+"): 0x0644, + ("m", "+"): 0x0645, + ("n", "+"): 0x0646, + ("h", "+"): 0x0647, + ("w", "+"): 0x0648, + ("j", "+"): 0x0649, + ("y", "+"): 0x064A, + (":", "+"): 0x064B, + ('"', "+"): 0x064C, + ("=", "+"): 0x064D, + ("/", "+"): 0x064E, + ("'", "+"): 0x064F, + ("1", "+"): 0x0650, + ("3", "+"): 0x0651, + ("0", "+"): 0x0652, + ("a", "S"): 0x0670, + ("p", "+"): 0x067E, + ("v", "+"): 0x06A4, + ("g", "f"): 0x06AF, + ("0", "a"): 0x06F0, + ("1", "a"): 0x06F1, + ("2", "a"): 0x06F2, + ("3", "a"): 0x06F3, + ("4", "a"): 0x06F4, + ("5", "a"): 0x06F5, + ("6", "a"): 0x06F6, + ("7", "a"): 0x06F7, + ("8", "a"): 0x06F8, + ("9", "a"): 0x06F9, + ("B", "."): 0x1E02, + ("b", "."): 0x1E03, + ("B", "_"): 0x1E06, + ("b", "_"): 0x1E07, + ("D", "."): 0x1E0A, + ("d", "."): 0x1E0B, + ("D", "_"): 0x1E0E, + ("d", "_"): 0x1E0F, + ("D", ","): 0x1E10, + ("d", ","): 0x1E11, + ("F", "."): 0x1E1E, + ("f", "."): 0x1E1F, + ("G", "-"): 0x1E20, + ("g", "-"): 0x1E21, + ("H", "."): 0x1E22, + ("h", "."): 0x1E23, + ("H", ":"): 0x1E26, + ("h", ":"): 0x1E27, + ("H", ","): 0x1E28, + ("h", ","): 0x1E29, + ("K", "'"): 0x1E30, + ("k", "'"): 0x1E31, + ("K", "_"): 0x1E34, + ("k", "_"): 0x1E35, + ("L", "_"): 0x1E3A, + ("l", "_"): 0x1E3B, + ("M", "'"): 0x1E3E, + ("m", "'"): 0x1E3F, + ("M", "."): 0x1E40, + ("m", "."): 0x1E41, + ("N", "."): 0x1E44, + ("n", "."): 0x1E45, + ("N", "_"): 0x1E48, + ("n", "_"): 0x1E49, + ("P", "'"): 0x1E54, + ("p", "'"): 0x1E55, + ("P", "."): 0x1E56, + ("p", "."): 0x1E57, + ("R", "."): 0x1E58, + ("r", "."): 0x1E59, + ("R", "_"): 0x1E5E, + ("r", "_"): 0x1E5F, + ("S", "."): 0x1E60, + ("s", "."): 0x1E61, + ("T", "."): 0x1E6A, + ("t", "."): 0x1E6B, + ("T", "_"): 0x1E6E, + ("t", "_"): 0x1E6F, + ("V", "?"): 0x1E7C, + ("v", "?"): 0x1E7D, + ("W", "!"): 0x1E80, + ("w", "!"): 0x1E81, + ("W", "'"): 0x1E82, + ("w", "'"): 0x1E83, + ("W", ":"): 0x1E84, + ("w", ":"): 0x1E85, + ("W", "."): 0x1E86, + ("w", "."): 0x1E87, + ("X", "."): 0x1E8A, + ("x", "."): 0x1E8B, + ("X", ":"): 0x1E8C, + ("x", ":"): 0x1E8D, + ("Y", "."): 0x1E8E, + ("y", "."): 0x1E8F, + ("Z", ">"): 0x1E90, + ("z", ">"): 0x1E91, + ("Z", "_"): 0x1E94, + ("z", "_"): 0x1E95, + ("h", "_"): 0x1E96, + ("t", ":"): 0x1E97, + ("w", "0"): 0x1E98, + ("y", "0"): 0x1E99, + ("A", "2"): 0x1EA2, + ("a", "2"): 0x1EA3, + ("E", "2"): 0x1EBA, + ("e", "2"): 0x1EBB, + ("E", "?"): 0x1EBC, + ("e", "?"): 0x1EBD, + ("I", "2"): 0x1EC8, + ("i", "2"): 0x1EC9, + ("O", "2"): 0x1ECE, + ("o", "2"): 0x1ECF, + ("U", "2"): 0x1EE6, + ("u", "2"): 0x1EE7, + ("Y", "!"): 0x1EF2, + ("y", "!"): 0x1EF3, + ("Y", "2"): 0x1EF6, + ("y", "2"): 0x1EF7, + ("Y", "?"): 0x1EF8, + ("y", "?"): 0x1EF9, + (";", "'"): 0x1F00, + (",", "'"): 0x1F01, + (";", "!"): 0x1F02, + (",", "!"): 0x1F03, + ("?", ";"): 0x1F04, + ("?", ","): 0x1F05, + ("!", ":"): 0x1F06, + ("?", ":"): 0x1F07, + ("1", "N"): 0x2002, + ("1", "M"): 0x2003, + ("3", "M"): 0x2004, + ("4", "M"): 0x2005, + ("6", "M"): 0x2006, + ("1", "T"): 0x2009, + ("1", "H"): 0x200A, + ("-", "1"): 0x2010, + ("-", "N"): 0x2013, + ("-", "M"): 0x2014, + ("-", "3"): 0x2015, + ("!", "2"): 0x2016, + ("=", "2"): 0x2017, + ("'", "6"): 0x2018, + ("'", "9"): 0x2019, + (".", "9"): 0x201A, + ("9", "'"): 0x201B, + ('"', "6"): 0x201C, + ('"', "9"): 0x201D, + (":", "9"): 0x201E, + ("9", '"'): 0x201F, + ("/", "-"): 0x2020, + ("/", "="): 0x2021, + (".", "."): 0x2025, + ("%", "0"): 0x2030, + ("1", "'"): 0x2032, + ("2", "'"): 0x2033, + ("3", "'"): 0x2034, + ("1", '"'): 0x2035, + ("2", '"'): 0x2036, + ("3", '"'): 0x2037, + ("C", "a"): 0x2038, + ("<", "1"): 0x2039, + (">", "1"): 0x203A, + (":", "X"): 0x203B, + ("'", "-"): 0x203E, + ("/", "f"): 0x2044, + ("0", "S"): 0x2070, + ("4", "S"): 0x2074, + ("5", "S"): 0x2075, + ("6", "S"): 0x2076, + ("7", "S"): 0x2077, + ("8", "S"): 0x2078, + ("9", "S"): 0x2079, + ("+", "S"): 0x207A, + ("-", "S"): 0x207B, + ("=", "S"): 0x207C, + ("(", "S"): 0x207D, + (")", "S"): 0x207E, + ("n", "S"): 0x207F, + ("0", "s"): 0x2080, + ("1", "s"): 0x2081, + ("2", "s"): 0x2082, + ("3", "s"): 0x2083, + ("4", "s"): 0x2084, + ("5", "s"): 0x2085, + ("6", "s"): 0x2086, + ("7", "s"): 0x2087, + ("8", "s"): 0x2088, + ("9", "s"): 0x2089, + ("+", "s"): 0x208A, + ("-", "s"): 0x208B, + ("=", "s"): 0x208C, + ("(", "s"): 0x208D, + (")", "s"): 0x208E, + ("L", "i"): 0x20A4, + ("P", "t"): 0x20A7, + ("W", "="): 0x20A9, + ("=", "e"): 0x20AC, # euro + ("E", "u"): 0x20AC, # euro + ("=", "R"): 0x20BD, # rouble + ("=", "P"): 0x20BD, # rouble + ("o", "C"): 0x2103, + ("c", "o"): 0x2105, + ("o", "F"): 0x2109, + ("N", "0"): 0x2116, + ("P", "O"): 0x2117, + ("R", "x"): 0x211E, + ("S", "M"): 0x2120, + ("T", "M"): 0x2122, + ("O", "m"): 0x2126, + ("A", "O"): 0x212B, + ("1", "3"): 0x2153, + ("2", "3"): 0x2154, + ("1", "5"): 0x2155, + ("2", "5"): 0x2156, + ("3", "5"): 0x2157, + ("4", "5"): 0x2158, + ("1", "6"): 0x2159, + ("5", "6"): 0x215A, + ("1", "8"): 0x215B, + ("3", "8"): 0x215C, + ("5", "8"): 0x215D, + ("7", "8"): 0x215E, + ("1", "R"): 0x2160, + ("2", "R"): 0x2161, + ("3", "R"): 0x2162, + ("4", "R"): 0x2163, + ("5", "R"): 0x2164, + ("6", "R"): 0x2165, + ("7", "R"): 0x2166, + ("8", "R"): 0x2167, + ("9", "R"): 0x2168, + ("a", "R"): 0x2169, + ("b", "R"): 0x216A, + ("c", "R"): 0x216B, + ("1", "r"): 0x2170, + ("2", "r"): 0x2171, + ("3", "r"): 0x2172, + ("4", "r"): 0x2173, + ("5", "r"): 0x2174, + ("6", "r"): 0x2175, + ("7", "r"): 0x2176, + ("8", "r"): 0x2177, + ("9", "r"): 0x2178, + ("a", "r"): 0x2179, + ("b", "r"): 0x217A, + ("c", "r"): 0x217B, + ("<", "-"): 0x2190, + ("-", "!"): 0x2191, + ("-", ">"): 0x2192, + ("-", "v"): 0x2193, + ("<", ">"): 0x2194, + ("U", "D"): 0x2195, + ("<", "="): 0x21D0, + ("=", ">"): 0x21D2, + ("=", "="): 0x21D4, + ("F", "A"): 0x2200, + ("d", "P"): 0x2202, + ("T", "E"): 0x2203, + ("/", "0"): 0x2205, + ("D", "E"): 0x2206, + ("N", "B"): 0x2207, + ("(", "-"): 0x2208, + ("-", ")"): 0x220B, + ("*", "P"): 0x220F, + ("+", "Z"): 0x2211, + ("-", "2"): 0x2212, + ("-", "+"): 0x2213, + ("*", "-"): 0x2217, + ("O", "b"): 0x2218, + ("S", "b"): 0x2219, + ("R", "T"): 0x221A, + ("0", "("): 0x221D, + ("0", "0"): 0x221E, + ("-", "L"): 0x221F, + ("-", "V"): 0x2220, + ("P", "P"): 0x2225, + ("A", "N"): 0x2227, + ("O", "R"): 0x2228, + ("(", "U"): 0x2229, + (")", "U"): 0x222A, + ("I", "n"): 0x222B, + ("D", "I"): 0x222C, + ("I", "o"): 0x222E, + (".", ":"): 0x2234, + (":", "."): 0x2235, + (":", "R"): 0x2236, + (":", ":"): 0x2237, + ("?", "1"): 0x223C, + ("C", "G"): 0x223E, + ("?", "-"): 0x2243, + ("?", "="): 0x2245, + ("?", "2"): 0x2248, + ("=", "?"): 0x224C, + ("H", "I"): 0x2253, + ("!", "="): 0x2260, + ("=", "3"): 0x2261, + ("=", "<"): 0x2264, + (">", "="): 0x2265, + ("<", "*"): 0x226A, + ("*", ">"): 0x226B, + ("!", "<"): 0x226E, + ("!", ">"): 0x226F, + ("(", "C"): 0x2282, + (")", "C"): 0x2283, + ("(", "_"): 0x2286, + (")", "_"): 0x2287, + ("0", "."): 0x2299, + ("0", "2"): 0x229A, + ("-", "T"): 0x22A5, + (".", "P"): 0x22C5, + (":", "3"): 0x22EE, + (".", "3"): 0x22EF, + ("E", "h"): 0x2302, + ("<", "7"): 0x2308, + (">", "7"): 0x2309, + ("7", "<"): 0x230A, + ("7", ">"): 0x230B, + ("N", "I"): 0x2310, + ("(", "A"): 0x2312, + ("T", "R"): 0x2315, + ("I", "u"): 0x2320, + ("I", "l"): 0x2321, + ("<", "/"): 0x2329, + ("/", ">"): 0x232A, + ("V", "s"): 0x2423, + ("1", "h"): 0x2440, + ("3", "h"): 0x2441, + ("2", "h"): 0x2442, + ("4", "h"): 0x2443, + ("1", "j"): 0x2446, + ("2", "j"): 0x2447, + ("3", "j"): 0x2448, + ("4", "j"): 0x2449, + ("1", "."): 0x2488, + ("2", "."): 0x2489, + ("3", "."): 0x248A, + ("4", "."): 0x248B, + ("5", "."): 0x248C, + ("6", "."): 0x248D, + ("7", "."): 0x248E, + ("8", "."): 0x248F, + ("9", "."): 0x2490, + ("h", "h"): 0x2500, + ("H", "H"): 0x2501, + ("v", "v"): 0x2502, + ("V", "V"): 0x2503, + ("3", "-"): 0x2504, + ("3", "_"): 0x2505, + ("3", "!"): 0x2506, + ("3", "/"): 0x2507, + ("4", "-"): 0x2508, + ("4", "_"): 0x2509, + ("4", "!"): 0x250A, + ("4", "/"): 0x250B, + ("d", "r"): 0x250C, + ("d", "R"): 0x250D, + ("D", "r"): 0x250E, + ("D", "R"): 0x250F, + ("d", "l"): 0x2510, + ("d", "L"): 0x2511, + ("D", "l"): 0x2512, + ("L", "D"): 0x2513, + ("u", "r"): 0x2514, + ("u", "R"): 0x2515, + ("U", "r"): 0x2516, + ("U", "R"): 0x2517, + ("u", "l"): 0x2518, + ("u", "L"): 0x2519, + ("U", "l"): 0x251A, + ("U", "L"): 0x251B, + ("v", "r"): 0x251C, + ("v", "R"): 0x251D, + ("V", "r"): 0x2520, + ("V", "R"): 0x2523, + ("v", "l"): 0x2524, + ("v", "L"): 0x2525, + ("V", "l"): 0x2528, + ("V", "L"): 0x252B, + ("d", "h"): 0x252C, + ("d", "H"): 0x252F, + ("D", "h"): 0x2530, + ("D", "H"): 0x2533, + ("u", "h"): 0x2534, + ("u", "H"): 0x2537, + ("U", "h"): 0x2538, + ("U", "H"): 0x253B, + ("v", "h"): 0x253C, + ("v", "H"): 0x253F, + ("V", "h"): 0x2542, + ("V", "H"): 0x254B, + ("F", "D"): 0x2571, + ("B", "D"): 0x2572, + ("T", "B"): 0x2580, + ("L", "B"): 0x2584, + ("F", "B"): 0x2588, + ("l", "B"): 0x258C, + ("R", "B"): 0x2590, + (".", "S"): 0x2591, + (":", "S"): 0x2592, + ("?", "S"): 0x2593, + ("f", "S"): 0x25A0, + ("O", "S"): 0x25A1, + ("R", "O"): 0x25A2, + ("R", "r"): 0x25A3, + ("R", "F"): 0x25A4, + ("R", "Y"): 0x25A5, + ("R", "H"): 0x25A6, + ("R", "Z"): 0x25A7, + ("R", "K"): 0x25A8, + ("R", "X"): 0x25A9, + ("s", "B"): 0x25AA, + ("S", "R"): 0x25AC, + ("O", "r"): 0x25AD, + ("U", "T"): 0x25B2, + ("u", "T"): 0x25B3, + ("P", "R"): 0x25B6, + ("T", "r"): 0x25B7, + ("D", "t"): 0x25BC, + ("d", "T"): 0x25BD, + ("P", "L"): 0x25C0, + ("T", "l"): 0x25C1, + ("D", "b"): 0x25C6, + ("D", "w"): 0x25C7, + ("L", "Z"): 0x25CA, + ("0", "m"): 0x25CB, + ("0", "o"): 0x25CE, + ("0", "M"): 0x25CF, + ("0", "L"): 0x25D0, + ("0", "R"): 0x25D1, + ("S", "n"): 0x25D8, + ("I", "c"): 0x25D9, + ("F", "d"): 0x25E2, + ("B", "d"): 0x25E3, + ("*", "2"): 0x2605, + ("*", "1"): 0x2606, + ("<", "H"): 0x261C, + (">", "H"): 0x261E, + ("0", "u"): 0x263A, + ("0", "U"): 0x263B, + ("S", "U"): 0x263C, + ("F", "m"): 0x2640, + ("M", "l"): 0x2642, + ("c", "S"): 0x2660, + ("c", "H"): 0x2661, + ("c", "D"): 0x2662, + ("c", "C"): 0x2663, + ("M", "d"): 0x2669, + ("M", "8"): 0x266A, + ("M", "2"): 0x266B, + ("M", "b"): 0x266D, + ("M", "x"): 0x266E, + ("M", "X"): 0x266F, + ("O", "K"): 0x2713, + ("X", "X"): 0x2717, + ("-", "X"): 0x2720, + ("I", "S"): 0x3000, + (",", "_"): 0x3001, + (".", "_"): 0x3002, + ("+", '"'): 0x3003, + ("+", "_"): 0x3004, + ("*", "_"): 0x3005, + (";", "_"): 0x3006, + ("0", "_"): 0x3007, + ("<", "+"): 0x300A, + (">", "+"): 0x300B, + ("<", "'"): 0x300C, + (">", "'"): 0x300D, + ("<", '"'): 0x300E, + (">", '"'): 0x300F, + ("(", '"'): 0x3010, + (")", '"'): 0x3011, + ("=", "T"): 0x3012, + ("=", "_"): 0x3013, + ("(", "'"): 0x3014, + (")", "'"): 0x3015, + ("(", "I"): 0x3016, + (")", "I"): 0x3017, + ("-", "?"): 0x301C, + ("A", "5"): 0x3041, + ("a", "5"): 0x3042, + ("I", "5"): 0x3043, + ("i", "5"): 0x3044, + ("U", "5"): 0x3045, + ("u", "5"): 0x3046, + ("E", "5"): 0x3047, + ("e", "5"): 0x3048, + ("O", "5"): 0x3049, + ("o", "5"): 0x304A, + ("k", "a"): 0x304B, + ("g", "a"): 0x304C, + ("k", "i"): 0x304D, + ("g", "i"): 0x304E, + ("k", "u"): 0x304F, + ("g", "u"): 0x3050, + ("k", "e"): 0x3051, + ("g", "e"): 0x3052, + ("k", "o"): 0x3053, + ("g", "o"): 0x3054, + ("s", "a"): 0x3055, + ("z", "a"): 0x3056, + ("s", "i"): 0x3057, + ("z", "i"): 0x3058, + ("s", "u"): 0x3059, + ("z", "u"): 0x305A, + ("s", "e"): 0x305B, + ("z", "e"): 0x305C, + ("s", "o"): 0x305D, + ("z", "o"): 0x305E, + ("t", "a"): 0x305F, + ("d", "a"): 0x3060, + ("t", "i"): 0x3061, + ("d", "i"): 0x3062, + ("t", "U"): 0x3063, + ("t", "u"): 0x3064, + ("d", "u"): 0x3065, + ("t", "e"): 0x3066, + ("d", "e"): 0x3067, + ("t", "o"): 0x3068, + ("d", "o"): 0x3069, + ("n", "a"): 0x306A, + ("n", "i"): 0x306B, + ("n", "u"): 0x306C, + ("n", "e"): 0x306D, + ("n", "o"): 0x306E, + ("h", "a"): 0x306F, + ("b", "a"): 0x3070, + ("p", "a"): 0x3071, + ("h", "i"): 0x3072, + ("b", "i"): 0x3073, + ("p", "i"): 0x3074, + ("h", "u"): 0x3075, + ("b", "u"): 0x3076, + ("p", "u"): 0x3077, + ("h", "e"): 0x3078, + ("b", "e"): 0x3079, + ("p", "e"): 0x307A, + ("h", "o"): 0x307B, + ("b", "o"): 0x307C, + ("p", "o"): 0x307D, + ("m", "a"): 0x307E, + ("m", "i"): 0x307F, + ("m", "u"): 0x3080, + ("m", "e"): 0x3081, + ("m", "o"): 0x3082, + ("y", "A"): 0x3083, + ("y", "a"): 0x3084, + ("y", "U"): 0x3085, + ("y", "u"): 0x3086, + ("y", "O"): 0x3087, + ("y", "o"): 0x3088, + ("r", "a"): 0x3089, + ("r", "i"): 0x308A, + ("r", "u"): 0x308B, + ("r", "e"): 0x308C, + ("r", "o"): 0x308D, + ("w", "A"): 0x308E, + ("w", "a"): 0x308F, + ("w", "i"): 0x3090, + ("w", "e"): 0x3091, + ("w", "o"): 0x3092, + ("n", "5"): 0x3093, + ("v", "u"): 0x3094, + ('"', "5"): 0x309B, + ("0", "5"): 0x309C, + ("*", "5"): 0x309D, + ("+", "5"): 0x309E, + ("a", "6"): 0x30A1, + ("A", "6"): 0x30A2, + ("i", "6"): 0x30A3, + ("I", "6"): 0x30A4, + ("u", "6"): 0x30A5, + ("U", "6"): 0x30A6, + ("e", "6"): 0x30A7, + ("E", "6"): 0x30A8, + ("o", "6"): 0x30A9, + ("O", "6"): 0x30AA, + ("K", "a"): 0x30AB, + ("G", "a"): 0x30AC, + ("K", "i"): 0x30AD, + ("G", "i"): 0x30AE, + ("K", "u"): 0x30AF, + ("G", "u"): 0x30B0, + ("K", "e"): 0x30B1, + ("G", "e"): 0x30B2, + ("K", "o"): 0x30B3, + ("G", "o"): 0x30B4, + ("S", "a"): 0x30B5, + ("Z", "a"): 0x30B6, + ("S", "i"): 0x30B7, + ("Z", "i"): 0x30B8, + ("S", "u"): 0x30B9, + ("Z", "u"): 0x30BA, + ("S", "e"): 0x30BB, + ("Z", "e"): 0x30BC, + ("S", "o"): 0x30BD, + ("Z", "o"): 0x30BE, + ("T", "a"): 0x30BF, + ("D", "a"): 0x30C0, + ("T", "i"): 0x30C1, + ("D", "i"): 0x30C2, + ("T", "U"): 0x30C3, + ("T", "u"): 0x30C4, + ("D", "u"): 0x30C5, + ("T", "e"): 0x30C6, + ("D", "e"): 0x30C7, + ("T", "o"): 0x30C8, + ("D", "o"): 0x30C9, + ("N", "a"): 0x30CA, + ("N", "i"): 0x30CB, + ("N", "u"): 0x30CC, + ("N", "e"): 0x30CD, + ("N", "o"): 0x30CE, + ("H", "a"): 0x30CF, + ("B", "a"): 0x30D0, + ("P", "a"): 0x30D1, + ("H", "i"): 0x30D2, + ("B", "i"): 0x30D3, + ("P", "i"): 0x30D4, + ("H", "u"): 0x30D5, + ("B", "u"): 0x30D6, + ("P", "u"): 0x30D7, + ("H", "e"): 0x30D8, + ("B", "e"): 0x30D9, + ("P", "e"): 0x30DA, + ("H", "o"): 0x30DB, + ("B", "o"): 0x30DC, + ("P", "o"): 0x30DD, + ("M", "a"): 0x30DE, + ("M", "i"): 0x30DF, + ("M", "u"): 0x30E0, + ("M", "e"): 0x30E1, + ("M", "o"): 0x30E2, + ("Y", "A"): 0x30E3, + ("Y", "a"): 0x30E4, + ("Y", "U"): 0x30E5, + ("Y", "u"): 0x30E6, + ("Y", "O"): 0x30E7, + ("Y", "o"): 0x30E8, + ("R", "a"): 0x30E9, + ("R", "i"): 0x30EA, + ("R", "u"): 0x30EB, + ("R", "e"): 0x30EC, + ("R", "o"): 0x30ED, + ("W", "A"): 0x30EE, + ("W", "a"): 0x30EF, + ("W", "i"): 0x30F0, + ("W", "e"): 0x30F1, + ("W", "o"): 0x30F2, + ("N", "6"): 0x30F3, + ("V", "u"): 0x30F4, + ("K", "A"): 0x30F5, + ("K", "E"): 0x30F6, + ("V", "a"): 0x30F7, + ("V", "i"): 0x30F8, + ("V", "e"): 0x30F9, + ("V", "o"): 0x30FA, + (".", "6"): 0x30FB, + ("-", "6"): 0x30FC, + ("*", "6"): 0x30FD, + ("+", "6"): 0x30FE, + ("b", "4"): 0x3105, + ("p", "4"): 0x3106, + ("m", "4"): 0x3107, + ("f", "4"): 0x3108, + ("d", "4"): 0x3109, + ("t", "4"): 0x310A, + ("n", "4"): 0x310B, + ("l", "4"): 0x310C, + ("g", "4"): 0x310D, + ("k", "4"): 0x310E, + ("h", "4"): 0x310F, + ("j", "4"): 0x3110, + ("q", "4"): 0x3111, + ("x", "4"): 0x3112, + ("z", "h"): 0x3113, + ("c", "h"): 0x3114, + ("s", "h"): 0x3115, + ("r", "4"): 0x3116, + ("z", "4"): 0x3117, + ("c", "4"): 0x3118, + ("s", "4"): 0x3119, + ("a", "4"): 0x311A, + ("o", "4"): 0x311B, + ("e", "4"): 0x311C, + ("a", "i"): 0x311E, + ("e", "i"): 0x311F, + ("a", "u"): 0x3120, + ("o", "u"): 0x3121, + ("a", "n"): 0x3122, + ("e", "n"): 0x3123, + ("a", "N"): 0x3124, + ("e", "N"): 0x3125, + ("e", "r"): 0x3126, + ("i", "4"): 0x3127, + ("u", "4"): 0x3128, + ("i", "u"): 0x3129, + ("v", "4"): 0x312A, + ("n", "G"): 0x312B, + ("g", "n"): 0x312C, + ("1", "c"): 0x3220, + ("2", "c"): 0x3221, + ("3", "c"): 0x3222, + ("4", "c"): 0x3223, + ("5", "c"): 0x3224, + ("6", "c"): 0x3225, + ("7", "c"): 0x3226, + ("8", "c"): 0x3227, + ("9", "c"): 0x3228, + # code points 0xe000 - 0xefff excluded, they have no assigned + # characters, only used in proposals. + ("f", "f"): 0xFB00, + ("f", "i"): 0xFB01, + ("f", "l"): 0xFB02, + ("f", "t"): 0xFB05, + ("s", "t"): 0xFB06, + # Vim 5.x compatible digraphs that don't conflict with the above + ("~", "!"): 161, + ("c", "|"): 162, + ("$", "$"): 163, + ("o", "x"): 164, # currency symbol in ISO 8859-1 + ("Y", "-"): 165, + ("|", "|"): 166, + ("c", "O"): 169, + ("-", ","): 172, + ("-", "="): 175, + ("~", "o"): 176, + ("2", "2"): 178, + ("3", "3"): 179, + ("p", "p"): 182, + ("~", "."): 183, + ("1", "1"): 185, + ("~", "?"): 191, + ("A", "`"): 192, + ("A", "^"): 194, + ("A", "~"): 195, + ("A", '"'): 196, + ("A", "@"): 197, + ("E", "`"): 200, + ("E", "^"): 202, + ("E", '"'): 203, + ("I", "`"): 204, + ("I", "^"): 206, + ("I", '"'): 207, + ("N", "~"): 209, + ("O", "`"): 210, + ("O", "^"): 212, + ("O", "~"): 213, + ("/", "\\"): 215, # multiplication symbol in ISO 8859-1 + ("U", "`"): 217, + ("U", "^"): 219, + ("I", "p"): 222, + ("a", "`"): 224, + ("a", "^"): 226, + ("a", "~"): 227, + ("a", '"'): 228, + ("a", "@"): 229, + ("e", "`"): 232, + ("e", "^"): 234, + ("e", '"'): 235, + ("i", "`"): 236, + ("i", "^"): 238, + ("n", "~"): 241, + ("o", "`"): 242, + ("o", "^"): 244, + ("o", "~"): 245, + ("u", "`"): 249, + ("u", "^"): 251, + ("y", '"'): 255, +} diff --git a/src/prompt_toolkit/key_binding/emacs_state.py b/src/prompt_toolkit/key_binding/emacs_state.py new file mode 100644 index 0000000..6a2ebf4 --- /dev/null +++ b/src/prompt_toolkit/key_binding/emacs_state.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from .key_processor import KeyPress + +__all__ = [ + "EmacsState", +] + + +class EmacsState: + """ + Mutable class to hold Emacs specific state. + """ + + def __init__(self) -> None: + # Simple macro recording. (Like Readline does.) + # (For Emacs mode.) + self.macro: list[KeyPress] | None = [] + self.current_recording: list[KeyPress] | None = None + + def reset(self) -> None: + self.current_recording = None + + @property + def is_recording(self) -> bool: + "Tell whether we are recording a macro." + return self.current_recording is not None + + def start_macro(self) -> None: + "Start recording macro." + self.current_recording = [] + + def end_macro(self) -> None: + "End recording macro." + self.macro = self.current_recording + self.current_recording = None diff --git a/src/prompt_toolkit/key_binding/key_bindings.py b/src/prompt_toolkit/key_binding/key_bindings.py new file mode 100644 index 0000000..62530f2 --- /dev/null +++ b/src/prompt_toolkit/key_binding/key_bindings.py @@ -0,0 +1,671 @@ +""" +Key bindings registry. + +A `KeyBindings` object is a container that holds a list of key bindings. It has a +very efficient internal data structure for checking which key bindings apply +for a pressed key. + +Typical usage:: + + kb = KeyBindings() + + @kb.add(Keys.ControlX, Keys.ControlC, filter=INSERT) + def handler(event): + # Handle ControlX-ControlC key sequence. + pass + +It is also possible to combine multiple KeyBindings objects. We do this in the +default key bindings. There are some KeyBindings objects that contain the Emacs +bindings, while others contain the Vi bindings. They are merged together using +`merge_key_bindings`. + +We also have a `ConditionalKeyBindings` object that can enable/disable a group of +key bindings at once. + + +It is also possible to add a filter to a function, before a key binding has +been assigned, through the `key_binding` decorator.:: + + # First define a key handler with the `filter`. + @key_binding(filter=condition) + def my_key_binding(event): + ... + + # Later, add it to the key bindings. + kb.add(Keys.A, my_key_binding) +""" +from __future__ import annotations + +from abc import ABCMeta, abstractmethod, abstractproperty +from inspect import isawaitable +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Coroutine, + Hashable, + Sequence, + Tuple, + TypeVar, + Union, + cast, +) + +from prompt_toolkit.cache import SimpleCache +from prompt_toolkit.filters import FilterOrBool, Never, to_filter +from prompt_toolkit.keys import KEY_ALIASES, Keys + +if TYPE_CHECKING: + # Avoid circular imports. + from .key_processor import KeyPressEvent + + # The only two return values for a mouse handler (and key bindings) are + # `None` and `NotImplemented`. For the type checker it's best to annotate + # this as `object`. (The consumer never expects a more specific instance: + # checking for NotImplemented can be done using `is NotImplemented`.) + NotImplementedOrNone = object + # Other non-working options are: + # * Optional[Literal[NotImplemented]] + # --> Doesn't work, Literal can't take an Any. + # * None + # --> Doesn't work. We can't assign the result of a function that + # returns `None` to a variable. + # * Any + # --> Works, but too broad. + + +__all__ = [ + "NotImplementedOrNone", + "Binding", + "KeyBindingsBase", + "KeyBindings", + "ConditionalKeyBindings", + "merge_key_bindings", + "DynamicKeyBindings", + "GlobalOnlyKeyBindings", +] + +# Key bindings can be regular functions or coroutines. +# In both cases, if they return `NotImplemented`, the UI won't be invalidated. +# This is mainly used in case of mouse move events, to prevent excessive +# repainting during mouse move events. +KeyHandlerCallable = Callable[ + ["KeyPressEvent"], + Union["NotImplementedOrNone", Coroutine[Any, Any, "NotImplementedOrNone"]], +] + + +class Binding: + """ + Key binding: (key sequence + handler + filter). + (Immutable binding class.) + + :param record_in_macro: When True, don't record this key binding when a + macro is recorded. + """ + + def __init__( + self, + keys: tuple[Keys | str, ...], + handler: KeyHandlerCallable, + filter: FilterOrBool = True, + eager: FilterOrBool = False, + is_global: FilterOrBool = False, + save_before: Callable[[KeyPressEvent], bool] = (lambda e: True), + record_in_macro: FilterOrBool = True, + ) -> None: + self.keys = keys + self.handler = handler + self.filter = to_filter(filter) + self.eager = to_filter(eager) + self.is_global = to_filter(is_global) + self.save_before = save_before + self.record_in_macro = to_filter(record_in_macro) + + def call(self, event: KeyPressEvent) -> None: + result = self.handler(event) + + # If the handler is a coroutine, create an asyncio task. + if isawaitable(result): + awaitable = cast(Coroutine[Any, Any, "NotImplementedOrNone"], result) + + async def bg_task() -> None: + result = await awaitable + if result != NotImplemented: + event.app.invalidate() + + event.app.create_background_task(bg_task()) + + elif result != NotImplemented: + event.app.invalidate() + + def __repr__(self) -> str: + return "{}(keys={!r}, handler={!r})".format( + self.__class__.__name__, + self.keys, + self.handler, + ) + + +# Sequence of keys presses. +KeysTuple = Tuple[Union[Keys, str], ...] + + +class KeyBindingsBase(metaclass=ABCMeta): + """ + Interface for a KeyBindings. + """ + + @abstractproperty + def _version(self) -> Hashable: + """ + For cache invalidation. - This should increase every time that + something changes. + """ + return 0 + + @abstractmethod + def get_bindings_for_keys(self, keys: KeysTuple) -> list[Binding]: + """ + Return a list of key bindings that can handle these keys. + (This return also inactive bindings, so the `filter` still has to be + called, for checking it.) + + :param keys: tuple of keys. + """ + return [] + + @abstractmethod + def get_bindings_starting_with_keys(self, keys: KeysTuple) -> list[Binding]: + """ + Return a list of key bindings that handle a key sequence starting with + `keys`. (It does only return bindings for which the sequences are + longer than `keys`. And like `get_bindings_for_keys`, it also includes + inactive bindings.) + + :param keys: tuple of keys. + """ + return [] + + @abstractproperty + def bindings(self) -> list[Binding]: + """ + List of `Binding` objects. + (These need to be exposed, so that `KeyBindings` objects can be merged + together.) + """ + return [] + + # `add` and `remove` don't have to be part of this interface. + + +T = TypeVar("T", bound=Union[KeyHandlerCallable, Binding]) + + +class KeyBindings(KeyBindingsBase): + """ + A container for a set of key bindings. + + Example usage:: + + kb = KeyBindings() + + @kb.add('c-t') + def _(event): + print('Control-T pressed') + + @kb.add('c-a', 'c-b') + def _(event): + print('Control-A pressed, followed by Control-B') + + @kb.add('c-x', filter=is_searching) + def _(event): + print('Control-X pressed') # Works only if we are searching. + + """ + + def __init__(self) -> None: + self._bindings: list[Binding] = [] + self._get_bindings_for_keys_cache: SimpleCache[ + KeysTuple, list[Binding] + ] = SimpleCache(maxsize=10000) + self._get_bindings_starting_with_keys_cache: SimpleCache[ + KeysTuple, list[Binding] + ] = SimpleCache(maxsize=1000) + self.__version = 0 # For cache invalidation. + + def _clear_cache(self) -> None: + self.__version += 1 + self._get_bindings_for_keys_cache.clear() + self._get_bindings_starting_with_keys_cache.clear() + + @property + def bindings(self) -> list[Binding]: + return self._bindings + + @property + def _version(self) -> Hashable: + return self.__version + + def add( + self, + *keys: Keys | str, + filter: FilterOrBool = True, + eager: FilterOrBool = False, + is_global: FilterOrBool = False, + save_before: Callable[[KeyPressEvent], bool] = (lambda e: True), + record_in_macro: FilterOrBool = True, + ) -> Callable[[T], T]: + """ + Decorator for adding a key bindings. + + :param filter: :class:`~prompt_toolkit.filters.Filter` to determine + when this key binding is active. + :param eager: :class:`~prompt_toolkit.filters.Filter` or `bool`. + When True, ignore potential longer matches when this key binding is + hit. E.g. when there is an active eager key binding for Ctrl-X, + execute the handler immediately and ignore the key binding for + Ctrl-X Ctrl-E of which it is a prefix. + :param is_global: When this key bindings is added to a `Container` or + `Control`, make it a global (always active) binding. + :param save_before: Callable that takes an `Event` and returns True if + we should save the current buffer, before handling the event. + (That's the default.) + :param record_in_macro: Record these key bindings when a macro is + being recorded. (True by default.) + """ + assert keys + + keys = tuple(_parse_key(k) for k in keys) + + if isinstance(filter, Never): + # When a filter is Never, it will always stay disabled, so in that + # case don't bother putting it in the key bindings. It will slow + # down every key press otherwise. + def decorator(func: T) -> T: + return func + + else: + + def decorator(func: T) -> T: + if isinstance(func, Binding): + # We're adding an existing Binding object. + self.bindings.append( + Binding( + keys, + func.handler, + filter=func.filter & to_filter(filter), + eager=to_filter(eager) | func.eager, + is_global=to_filter(is_global) | func.is_global, + save_before=func.save_before, + record_in_macro=func.record_in_macro, + ) + ) + else: + self.bindings.append( + Binding( + keys, + cast(KeyHandlerCallable, func), + filter=filter, + eager=eager, + is_global=is_global, + save_before=save_before, + record_in_macro=record_in_macro, + ) + ) + self._clear_cache() + + return func + + return decorator + + def remove(self, *args: Keys | str | KeyHandlerCallable) -> None: + """ + Remove a key binding. + + This expects either a function that was given to `add` method as + parameter or a sequence of key bindings. + + Raises `ValueError` when no bindings was found. + + Usage:: + + remove(handler) # Pass handler. + remove('c-x', 'c-a') # Or pass the key bindings. + """ + found = False + + if callable(args[0]): + assert len(args) == 1 + function = args[0] + + # Remove the given function. + for b in self.bindings: + if b.handler == function: + self.bindings.remove(b) + found = True + + else: + assert len(args) > 0 + args = cast(Tuple[Union[Keys, str]], args) + + # Remove this sequence of key bindings. + keys = tuple(_parse_key(k) for k in args) + + for b in self.bindings: + if b.keys == keys: + self.bindings.remove(b) + found = True + + if found: + self._clear_cache() + else: + # No key binding found for this function. Raise ValueError. + raise ValueError(f"Binding not found: {function!r}") + + # For backwards-compatibility. + add_binding = add + remove_binding = remove + + def get_bindings_for_keys(self, keys: KeysTuple) -> list[Binding]: + """ + Return a list of key bindings that can handle this key. + (This return also inactive bindings, so the `filter` still has to be + called, for checking it.) + + :param keys: tuple of keys. + """ + + def get() -> list[Binding]: + result: list[tuple[int, Binding]] = [] + + for b in self.bindings: + if len(keys) == len(b.keys): + match = True + any_count = 0 + + for i, j in zip(b.keys, keys): + if i != j and i != Keys.Any: + match = False + break + + if i == Keys.Any: + any_count += 1 + + if match: + result.append((any_count, b)) + + # Place bindings that have more 'Any' occurrences in them at the end. + result = sorted(result, key=lambda item: -item[0]) + + return [item[1] for item in result] + + return self._get_bindings_for_keys_cache.get(keys, get) + + def get_bindings_starting_with_keys(self, keys: KeysTuple) -> list[Binding]: + """ + Return a list of key bindings that handle a key sequence starting with + `keys`. (It does only return bindings for which the sequences are + longer than `keys`. And like `get_bindings_for_keys`, it also includes + inactive bindings.) + + :param keys: tuple of keys. + """ + + def get() -> list[Binding]: + result = [] + for b in self.bindings: + if len(keys) < len(b.keys): + match = True + for i, j in zip(b.keys, keys): + if i != j and i != Keys.Any: + match = False + break + if match: + result.append(b) + return result + + return self._get_bindings_starting_with_keys_cache.get(keys, get) + + +def _parse_key(key: Keys | str) -> str | Keys: + """ + Replace key by alias and verify whether it's a valid one. + """ + # Already a parse key? -> Return it. + if isinstance(key, Keys): + return key + + # Lookup aliases. + key = KEY_ALIASES.get(key, key) + + # Replace 'space' by ' ' + if key == "space": + key = " " + + # Return as `Key` object when it's a special key. + try: + return Keys(key) + except ValueError: + pass + + # Final validation. + if len(key) != 1: + raise ValueError(f"Invalid key: {key}") + + return key + + +def key_binding( + filter: FilterOrBool = True, + eager: FilterOrBool = False, + is_global: FilterOrBool = False, + save_before: Callable[[KeyPressEvent], bool] = (lambda event: True), + record_in_macro: FilterOrBool = True, +) -> Callable[[KeyHandlerCallable], Binding]: + """ + Decorator that turn a function into a `Binding` object. This can be added + to a `KeyBindings` object when a key binding is assigned. + """ + assert save_before is None or callable(save_before) + + filter = to_filter(filter) + eager = to_filter(eager) + is_global = to_filter(is_global) + save_before = save_before + record_in_macro = to_filter(record_in_macro) + keys = () + + def decorator(function: KeyHandlerCallable) -> Binding: + return Binding( + keys, + function, + filter=filter, + eager=eager, + is_global=is_global, + save_before=save_before, + record_in_macro=record_in_macro, + ) + + return decorator + + +class _Proxy(KeyBindingsBase): + """ + Common part for ConditionalKeyBindings and _MergedKeyBindings. + """ + + def __init__(self) -> None: + # `KeyBindings` to be synchronized with all the others. + self._bindings2: KeyBindingsBase = KeyBindings() + self._last_version: Hashable = () + + def _update_cache(self) -> None: + """ + If `self._last_version` is outdated, then this should update + the version and `self._bindings2`. + """ + raise NotImplementedError + + # Proxy methods to self._bindings2. + + @property + def bindings(self) -> list[Binding]: + self._update_cache() + return self._bindings2.bindings + + @property + def _version(self) -> Hashable: + self._update_cache() + return self._last_version + + def get_bindings_for_keys(self, keys: KeysTuple) -> list[Binding]: + self._update_cache() + return self._bindings2.get_bindings_for_keys(keys) + + def get_bindings_starting_with_keys(self, keys: KeysTuple) -> list[Binding]: + self._update_cache() + return self._bindings2.get_bindings_starting_with_keys(keys) + + +class ConditionalKeyBindings(_Proxy): + """ + Wraps around a `KeyBindings`. Disable/enable all the key bindings according to + the given (additional) filter.:: + + @Condition + def setting_is_true(): + return True # or False + + registry = ConditionalKeyBindings(key_bindings, setting_is_true) + + When new key bindings are added to this object. They are also + enable/disabled according to the given `filter`. + + :param registries: List of :class:`.KeyBindings` objects. + :param filter: :class:`~prompt_toolkit.filters.Filter` object. + """ + + def __init__( + self, key_bindings: KeyBindingsBase, filter: FilterOrBool = True + ) -> None: + _Proxy.__init__(self) + + self.key_bindings = key_bindings + self.filter = to_filter(filter) + + def _update_cache(self) -> None: + "If the original key bindings was changed. Update our copy version." + expected_version = self.key_bindings._version + + if self._last_version != expected_version: + bindings2 = KeyBindings() + + # Copy all bindings from `self.key_bindings`, adding our condition. + for b in self.key_bindings.bindings: + bindings2.bindings.append( + Binding( + keys=b.keys, + handler=b.handler, + filter=self.filter & b.filter, + eager=b.eager, + is_global=b.is_global, + save_before=b.save_before, + record_in_macro=b.record_in_macro, + ) + ) + + self._bindings2 = bindings2 + self._last_version = expected_version + + +class _MergedKeyBindings(_Proxy): + """ + Merge multiple registries of key bindings into one. + + This class acts as a proxy to multiple :class:`.KeyBindings` objects, but + behaves as if this is just one bigger :class:`.KeyBindings`. + + :param registries: List of :class:`.KeyBindings` objects. + """ + + def __init__(self, registries: Sequence[KeyBindingsBase]) -> None: + _Proxy.__init__(self) + self.registries = registries + + def _update_cache(self) -> None: + """ + If one of the original registries was changed. Update our merged + version. + """ + expected_version = tuple(r._version for r in self.registries) + + if self._last_version != expected_version: + bindings2 = KeyBindings() + + for reg in self.registries: + bindings2.bindings.extend(reg.bindings) + + self._bindings2 = bindings2 + self._last_version = expected_version + + +def merge_key_bindings(bindings: Sequence[KeyBindingsBase]) -> _MergedKeyBindings: + """ + Merge multiple :class:`.Keybinding` objects together. + + Usage:: + + bindings = merge_key_bindings([bindings1, bindings2, ...]) + """ + return _MergedKeyBindings(bindings) + + +class DynamicKeyBindings(_Proxy): + """ + KeyBindings class that can dynamically returns any KeyBindings. + + :param get_key_bindings: Callable that returns a :class:`.KeyBindings` instance. + """ + + def __init__(self, get_key_bindings: Callable[[], KeyBindingsBase | None]) -> None: + self.get_key_bindings = get_key_bindings + self.__version = 0 + self._last_child_version = None + self._dummy = KeyBindings() # Empty key bindings. + + def _update_cache(self) -> None: + key_bindings = self.get_key_bindings() or self._dummy + assert isinstance(key_bindings, KeyBindingsBase) + version = id(key_bindings), key_bindings._version + + self._bindings2 = key_bindings + self._last_version = version + + +class GlobalOnlyKeyBindings(_Proxy): + """ + Wrapper around a :class:`.KeyBindings` object that only exposes the global + key bindings. + """ + + def __init__(self, key_bindings: KeyBindingsBase) -> None: + _Proxy.__init__(self) + self.key_bindings = key_bindings + + def _update_cache(self) -> None: + """ + If one of the original registries was changed. Update our merged + version. + """ + expected_version = self.key_bindings._version + + if self._last_version != expected_version: + bindings2 = KeyBindings() + + for b in self.key_bindings.bindings: + if b.is_global(): + bindings2.bindings.append(b) + + self._bindings2 = bindings2 + self._last_version = expected_version diff --git a/src/prompt_toolkit/key_binding/key_processor.py b/src/prompt_toolkit/key_binding/key_processor.py new file mode 100644 index 0000000..4c4f0d1 --- /dev/null +++ b/src/prompt_toolkit/key_binding/key_processor.py @@ -0,0 +1,529 @@ +""" +An :class:`~.KeyProcessor` receives callbacks for the keystrokes parsed from +the input in the :class:`~prompt_toolkit.inputstream.InputStream` instance. + +The `KeyProcessor` will according to the implemented keybindings call the +correct callbacks when new key presses are feed through `feed`. +""" +from __future__ import annotations + +import weakref +from asyncio import Task, sleep +from collections import deque +from typing import TYPE_CHECKING, Any, Generator + +from prompt_toolkit.application.current import get_app +from prompt_toolkit.enums import EditingMode +from prompt_toolkit.filters.app import vi_navigation_mode +from prompt_toolkit.keys import Keys +from prompt_toolkit.utils import Event + +from .key_bindings import Binding, KeyBindingsBase + +if TYPE_CHECKING: + from prompt_toolkit.application import Application + from prompt_toolkit.buffer import Buffer + + +__all__ = [ + "KeyProcessor", + "KeyPress", + "KeyPressEvent", +] + + +class KeyPress: + """ + :param key: A `Keys` instance or text (one character). + :param data: The received string on stdin. (Often vt100 escape codes.) + """ + + def __init__(self, key: Keys | str, data: str | None = None) -> None: + assert isinstance(key, Keys) or len(key) == 1 + + if data is None: + if isinstance(key, Keys): + data = key.value + else: + data = key # 'key' is a one character string. + + self.key = key + self.data = data + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(key={self.key!r}, data={self.data!r})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, KeyPress): + return False + return self.key == other.key and self.data == other.data + + +""" +Helper object to indicate flush operation in the KeyProcessor. +NOTE: the implementation is very similar to the VT100 parser. +""" +_Flush = KeyPress("?", data="_Flush") + + +class KeyProcessor: + """ + Statemachine that receives :class:`KeyPress` instances and according to the + key bindings in the given :class:`KeyBindings`, calls the matching handlers. + + :: + + p = KeyProcessor(key_bindings) + + # Send keys into the processor. + p.feed(KeyPress(Keys.ControlX, '\x18')) + p.feed(KeyPress(Keys.ControlC, '\x03') + + # Process all the keys in the queue. + p.process_keys() + + # Now the ControlX-ControlC callback will be called if this sequence is + # registered in the key bindings. + + :param key_bindings: `KeyBindingsBase` instance. + """ + + def __init__(self, key_bindings: KeyBindingsBase) -> None: + self._bindings = key_bindings + + self.before_key_press = Event(self) + self.after_key_press = Event(self) + + self._flush_wait_task: Task[None] | None = None + + self.reset() + + def reset(self) -> None: + self._previous_key_sequence: list[KeyPress] = [] + self._previous_handler: Binding | None = None + + # The queue of keys not yet send to our _process generator/state machine. + self.input_queue: deque[KeyPress] = deque() + + # The key buffer that is matched in the generator state machine. + # (This is at at most the amount of keys that make up for one key binding.) + self.key_buffer: list[KeyPress] = [] + + #: Readline argument (for repetition of commands.) + #: https://www.gnu.org/software/bash/manual/html_node/Readline-Arguments.html + self.arg: str | None = None + + # Start the processor coroutine. + self._process_coroutine = self._process() + self._process_coroutine.send(None) # type: ignore + + def _get_matches(self, key_presses: list[KeyPress]) -> list[Binding]: + """ + For a list of :class:`KeyPress` instances. Give the matching handlers + that would handle this. + """ + keys = tuple(k.key for k in key_presses) + + # Try match, with mode flag + return [b for b in self._bindings.get_bindings_for_keys(keys) if b.filter()] + + def _is_prefix_of_longer_match(self, key_presses: list[KeyPress]) -> bool: + """ + For a list of :class:`KeyPress` instances. Return True if there is any + handler that is bound to a suffix of this keys. + """ + keys = tuple(k.key for k in key_presses) + + # Get the filters for all the key bindings that have a longer match. + # Note that we transform it into a `set`, because we don't care about + # the actual bindings and executing it more than once doesn't make + # sense. (Many key bindings share the same filter.) + filters = { + b.filter for b in self._bindings.get_bindings_starting_with_keys(keys) + } + + # When any key binding is active, return True. + return any(f() for f in filters) + + def _process(self) -> Generator[None, KeyPress, None]: + """ + Coroutine implementing the key match algorithm. Key strokes are sent + into this generator, and it calls the appropriate handlers. + """ + buffer = self.key_buffer + retry = False + + while True: + flush = False + + if retry: + retry = False + else: + key = yield + if key is _Flush: + flush = True + else: + buffer.append(key) + + # If we have some key presses, check for matches. + if buffer: + matches = self._get_matches(buffer) + + if flush: + is_prefix_of_longer_match = False + else: + is_prefix_of_longer_match = self._is_prefix_of_longer_match(buffer) + + # When eager matches were found, give priority to them and also + # ignore all the longer matches. + eager_matches = [m for m in matches if m.eager()] + + if eager_matches: + matches = eager_matches + is_prefix_of_longer_match = False + + # Exact matches found, call handler. + if not is_prefix_of_longer_match and matches: + self._call_handler(matches[-1], key_sequence=buffer[:]) + del buffer[:] # Keep reference. + + # No match found. + elif not is_prefix_of_longer_match and not matches: + retry = True + found = False + + # Loop over the input, try longest match first and shift. + for i in range(len(buffer), 0, -1): + matches = self._get_matches(buffer[:i]) + if matches: + self._call_handler(matches[-1], key_sequence=buffer[:i]) + del buffer[:i] + found = True + break + + if not found: + del buffer[:1] + + def feed(self, key_press: KeyPress, first: bool = False) -> None: + """ + Add a new :class:`KeyPress` to the input queue. + (Don't forget to call `process_keys` in order to process the queue.) + + :param first: If true, insert before everything else. + """ + if first: + self.input_queue.appendleft(key_press) + else: + self.input_queue.append(key_press) + + def feed_multiple(self, key_presses: list[KeyPress], first: bool = False) -> None: + """ + :param first: If true, insert before everything else. + """ + if first: + self.input_queue.extendleft(reversed(key_presses)) + else: + self.input_queue.extend(key_presses) + + def process_keys(self) -> None: + """ + Process all the keys in the `input_queue`. + (To be called after `feed`.) + + Note: because of the `feed`/`process_keys` separation, it is + possible to call `feed` from inside a key binding. + This function keeps looping until the queue is empty. + """ + app = get_app() + + def not_empty() -> bool: + # When the application result is set, stop processing keys. (E.g. + # if ENTER was received, followed by a few additional key strokes, + # leave the other keys in the queue.) + if app.is_done: + # But if there are still CPRResponse keys in the queue, these + # need to be processed. + return any(k for k in self.input_queue if k.key == Keys.CPRResponse) + else: + return bool(self.input_queue) + + def get_next() -> KeyPress: + if app.is_done: + # Only process CPR responses. Everything else is typeahead. + cpr = [k for k in self.input_queue if k.key == Keys.CPRResponse][0] + self.input_queue.remove(cpr) + return cpr + else: + return self.input_queue.popleft() + + is_flush = False + + while not_empty(): + # Process next key. + key_press = get_next() + + is_flush = key_press is _Flush + is_cpr = key_press.key == Keys.CPRResponse + + if not is_flush and not is_cpr: + self.before_key_press.fire() + + try: + self._process_coroutine.send(key_press) + except Exception: + # If for some reason something goes wrong in the parser, (maybe + # an exception was raised) restart the processor for next time. + self.reset() + self.empty_queue() + raise + + if not is_flush and not is_cpr: + self.after_key_press.fire() + + # Skip timeout if the last key was flush. + if not is_flush: + self._start_timeout() + + def empty_queue(self) -> list[KeyPress]: + """ + Empty the input queue. Return the unprocessed input. + """ + key_presses = list(self.input_queue) + self.input_queue.clear() + + # Filter out CPRs. We don't want to return these. + key_presses = [k for k in key_presses if k.key != Keys.CPRResponse] + return key_presses + + def _call_handler(self, handler: Binding, key_sequence: list[KeyPress]) -> None: + app = get_app() + was_recording_emacs = app.emacs_state.is_recording + was_recording_vi = bool(app.vi_state.recording_register) + was_temporary_navigation_mode = app.vi_state.temporary_navigation_mode + arg = self.arg + self.arg = None + + event = KeyPressEvent( + weakref.ref(self), + arg=arg, + key_sequence=key_sequence, + previous_key_sequence=self._previous_key_sequence, + is_repeat=(handler == self._previous_handler), + ) + + # Save the state of the current buffer. + if handler.save_before(event): + event.app.current_buffer.save_to_undo_stack() + + # Call handler. + from prompt_toolkit.buffer import EditReadOnlyBuffer + + try: + handler.call(event) + self._fix_vi_cursor_position(event) + + except EditReadOnlyBuffer: + # When a key binding does an attempt to change a buffer which is + # read-only, we can ignore that. We sound a bell and go on. + app.output.bell() + + if was_temporary_navigation_mode: + self._leave_vi_temp_navigation_mode(event) + + self._previous_key_sequence = key_sequence + self._previous_handler = handler + + # Record the key sequence in our macro. (Only if we're in macro mode + # before and after executing the key.) + if handler.record_in_macro(): + if app.emacs_state.is_recording and was_recording_emacs: + recording = app.emacs_state.current_recording + if recording is not None: # Should always be true, given that + # `was_recording_emacs` is set. + recording.extend(key_sequence) + + if app.vi_state.recording_register and was_recording_vi: + for k in key_sequence: + app.vi_state.current_recording += k.data + + def _fix_vi_cursor_position(self, event: KeyPressEvent) -> None: + """ + After every command, make sure that if we are in Vi navigation mode, we + never put the cursor after the last character of a line. (Unless it's + an empty line.) + """ + app = event.app + buff = app.current_buffer + preferred_column = buff.preferred_column + + if ( + vi_navigation_mode() + and buff.document.is_cursor_at_the_end_of_line + and len(buff.document.current_line) > 0 + ): + buff.cursor_position -= 1 + + # Set the preferred_column for arrow up/down again. + # (This was cleared after changing the cursor position.) + buff.preferred_column = preferred_column + + def _leave_vi_temp_navigation_mode(self, event: KeyPressEvent) -> None: + """ + If we're in Vi temporary navigation (normal) mode, return to + insert/replace mode after executing one action. + """ + app = event.app + + if app.editing_mode == EditingMode.VI: + # Not waiting for a text object and no argument has been given. + if app.vi_state.operator_func is None and self.arg is None: + app.vi_state.temporary_navigation_mode = False + + def _start_timeout(self) -> None: + """ + Start auto flush timeout. Similar to Vim's `timeoutlen` option. + + Start a background coroutine with a timer. When this timeout expires + and no key was pressed in the meantime, we flush all data in the queue + and call the appropriate key binding handlers. + """ + app = get_app() + timeout = app.timeoutlen + + if timeout is None: + return + + async def wait() -> None: + "Wait for timeout." + # This sleep can be cancelled. In that case we don't flush. + await sleep(timeout) + + if len(self.key_buffer) > 0: + # (No keys pressed in the meantime.) + flush_keys() + + def flush_keys() -> None: + "Flush keys." + self.feed(_Flush) + self.process_keys() + + # Automatically flush keys. + if self._flush_wait_task: + self._flush_wait_task.cancel() + self._flush_wait_task = app.create_background_task(wait()) + + def send_sigint(self) -> None: + """ + Send SIGINT. Immediately call the SIGINT key handler. + """ + self.feed(KeyPress(key=Keys.SIGINT), first=True) + self.process_keys() + + +class KeyPressEvent: + """ + Key press event, delivered to key bindings. + + :param key_processor_ref: Weak reference to the `KeyProcessor`. + :param arg: Repetition argument. + :param key_sequence: List of `KeyPress` instances. + :param previouskey_sequence: Previous list of `KeyPress` instances. + :param is_repeat: True when the previous event was delivered to the same handler. + """ + + def __init__( + self, + key_processor_ref: weakref.ReferenceType[KeyProcessor], + arg: str | None, + key_sequence: list[KeyPress], + previous_key_sequence: list[KeyPress], + is_repeat: bool, + ) -> None: + self._key_processor_ref = key_processor_ref + self.key_sequence = key_sequence + self.previous_key_sequence = previous_key_sequence + + #: True when the previous key sequence was handled by the same handler. + self.is_repeat = is_repeat + + self._arg = arg + self._app = get_app() + + def __repr__(self) -> str: + return "KeyPressEvent(arg={!r}, key_sequence={!r}, is_repeat={!r})".format( + self.arg, + self.key_sequence, + self.is_repeat, + ) + + @property + def data(self) -> str: + return self.key_sequence[-1].data + + @property + def key_processor(self) -> KeyProcessor: + processor = self._key_processor_ref() + if processor is None: + raise Exception("KeyProcessor was lost. This should not happen.") + return processor + + @property + def app(self) -> Application[Any]: + """ + The current `Application` object. + """ + return self._app + + @property + def current_buffer(self) -> Buffer: + """ + The current buffer. + """ + return self.app.current_buffer + + @property + def arg(self) -> int: + """ + Repetition argument. + """ + if self._arg == "-": + return -1 + + result = int(self._arg or 1) + + # Don't exceed a million. + if int(result) >= 1000000: + result = 1 + + return result + + @property + def arg_present(self) -> bool: + """ + True if repetition argument was explicitly provided. + """ + return self._arg is not None + + def append_to_arg_count(self, data: str) -> None: + """ + Add digit to the input argument. + + :param data: the typed digit as string + """ + assert data in "-0123456789" + current = self._arg + + if data == "-": + assert current is None or current == "-" + result = data + elif current is None: + result = data + else: + result = f"{current}{data}" + + self.key_processor.arg = result + + @property + def cli(self) -> Application[Any]: + "For backward-compatibility." + return self.app diff --git a/src/prompt_toolkit/key_binding/vi_state.py b/src/prompt_toolkit/key_binding/vi_state.py new file mode 100644 index 0000000..7ec552f --- /dev/null +++ b/src/prompt_toolkit/key_binding/vi_state.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from enum import Enum +from typing import TYPE_CHECKING, Callable + +from prompt_toolkit.clipboard import ClipboardData + +if TYPE_CHECKING: + from .key_bindings.vi import TextObject + from .key_processor import KeyPressEvent + +__all__ = [ + "InputMode", + "CharacterFind", + "ViState", +] + + +class InputMode(str, Enum): + value: str + + INSERT = "vi-insert" + INSERT_MULTIPLE = "vi-insert-multiple" + NAVIGATION = "vi-navigation" # Normal mode. + REPLACE = "vi-replace" + REPLACE_SINGLE = "vi-replace-single" + + +class CharacterFind: + def __init__(self, character: str, backwards: bool = False) -> None: + self.character = character + self.backwards = backwards + + +class ViState: + """ + Mutable class to hold the state of the Vi navigation. + """ + + def __init__(self) -> None: + #: None or CharacterFind instance. (This is used to repeat the last + #: search in Vi mode, by pressing the 'n' or 'N' in navigation mode.) + self.last_character_find: CharacterFind | None = None + + # When an operator is given and we are waiting for text object, + # -- e.g. in the case of 'dw', after the 'd' --, an operator callback + # is set here. + self.operator_func: None | (Callable[[KeyPressEvent, TextObject], None]) = None + self.operator_arg: int | None = None + + #: Named registers. Maps register name (e.g. 'a') to + #: :class:`ClipboardData` instances. + self.named_registers: dict[str, ClipboardData] = {} + + #: The Vi mode we're currently in to. + self.__input_mode = InputMode.INSERT + + #: Waiting for digraph. + self.waiting_for_digraph = False + self.digraph_symbol1: str | None = None # (None or a symbol.) + + #: When true, make ~ act as an operator. + self.tilde_operator = False + + #: Register in which we are recording a macro. + #: `None` when not recording anything. + # Note that the recording is only stored in the register after the + # recording is stopped. So we record in a separate `current_recording` + # variable. + self.recording_register: str | None = None + self.current_recording: str = "" + + # Temporary navigation (normal) mode. + # This happens when control-o has been pressed in insert or replace + # mode. The user can now do one navigation action and we'll return back + # to insert/replace. + self.temporary_navigation_mode = False + + @property + def input_mode(self) -> InputMode: + "Get `InputMode`." + return self.__input_mode + + @input_mode.setter + def input_mode(self, value: InputMode) -> None: + "Set `InputMode`." + if value == InputMode.NAVIGATION: + self.waiting_for_digraph = False + self.operator_func = None + self.operator_arg = None + + self.__input_mode = value + + def reset(self) -> None: + """ + Reset state, go back to the given mode. INSERT by default. + """ + # Go back to insert mode. + self.input_mode = InputMode.INSERT + + self.waiting_for_digraph = False + self.operator_func = None + self.operator_arg = None + + # Reset recording state. + self.recording_register = None + self.current_recording = "" diff --git a/src/prompt_toolkit/keys.py b/src/prompt_toolkit/keys.py new file mode 100644 index 0000000..ee52aee --- /dev/null +++ b/src/prompt_toolkit/keys.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +from enum import Enum + +__all__ = [ + "Keys", + "ALL_KEYS", +] + + +class Keys(str, Enum): + """ + List of keys for use in key bindings. + + Note that this is an "StrEnum", all values can be compared against + strings. + """ + + value: str + + Escape = "escape" # Also Control-[ + ShiftEscape = "s-escape" + + ControlAt = "c-@" # Also Control-Space. + + ControlA = "c-a" + ControlB = "c-b" + ControlC = "c-c" + ControlD = "c-d" + ControlE = "c-e" + ControlF = "c-f" + ControlG = "c-g" + ControlH = "c-h" + ControlI = "c-i" # Tab + ControlJ = "c-j" # Newline + ControlK = "c-k" + ControlL = "c-l" + ControlM = "c-m" # Carriage return + ControlN = "c-n" + ControlO = "c-o" + ControlP = "c-p" + ControlQ = "c-q" + ControlR = "c-r" + ControlS = "c-s" + ControlT = "c-t" + ControlU = "c-u" + ControlV = "c-v" + ControlW = "c-w" + ControlX = "c-x" + ControlY = "c-y" + ControlZ = "c-z" + + Control1 = "c-1" + Control2 = "c-2" + Control3 = "c-3" + Control4 = "c-4" + Control5 = "c-5" + Control6 = "c-6" + Control7 = "c-7" + Control8 = "c-8" + Control9 = "c-9" + Control0 = "c-0" + + ControlShift1 = "c-s-1" + ControlShift2 = "c-s-2" + ControlShift3 = "c-s-3" + ControlShift4 = "c-s-4" + ControlShift5 = "c-s-5" + ControlShift6 = "c-s-6" + ControlShift7 = "c-s-7" + ControlShift8 = "c-s-8" + ControlShift9 = "c-s-9" + ControlShift0 = "c-s-0" + + ControlBackslash = "c-\\" + ControlSquareClose = "c-]" + ControlCircumflex = "c-^" + ControlUnderscore = "c-_" + + Left = "left" + Right = "right" + Up = "up" + Down = "down" + Home = "home" + End = "end" + Insert = "insert" + Delete = "delete" + PageUp = "pageup" + PageDown = "pagedown" + + ControlLeft = "c-left" + ControlRight = "c-right" + ControlUp = "c-up" + ControlDown = "c-down" + ControlHome = "c-home" + ControlEnd = "c-end" + ControlInsert = "c-insert" + ControlDelete = "c-delete" + ControlPageUp = "c-pageup" + ControlPageDown = "c-pagedown" + + ShiftLeft = "s-left" + ShiftRight = "s-right" + ShiftUp = "s-up" + ShiftDown = "s-down" + ShiftHome = "s-home" + ShiftEnd = "s-end" + ShiftInsert = "s-insert" + ShiftDelete = "s-delete" + ShiftPageUp = "s-pageup" + ShiftPageDown = "s-pagedown" + + ControlShiftLeft = "c-s-left" + ControlShiftRight = "c-s-right" + ControlShiftUp = "c-s-up" + ControlShiftDown = "c-s-down" + ControlShiftHome = "c-s-home" + ControlShiftEnd = "c-s-end" + ControlShiftInsert = "c-s-insert" + ControlShiftDelete = "c-s-delete" + ControlShiftPageUp = "c-s-pageup" + ControlShiftPageDown = "c-s-pagedown" + + BackTab = "s-tab" # shift + tab + + F1 = "f1" + F2 = "f2" + F3 = "f3" + F4 = "f4" + F5 = "f5" + F6 = "f6" + F7 = "f7" + F8 = "f8" + F9 = "f9" + F10 = "f10" + F11 = "f11" + F12 = "f12" + F13 = "f13" + F14 = "f14" + F15 = "f15" + F16 = "f16" + F17 = "f17" + F18 = "f18" + F19 = "f19" + F20 = "f20" + F21 = "f21" + F22 = "f22" + F23 = "f23" + F24 = "f24" + + ControlF1 = "c-f1" + ControlF2 = "c-f2" + ControlF3 = "c-f3" + ControlF4 = "c-f4" + ControlF5 = "c-f5" + ControlF6 = "c-f6" + ControlF7 = "c-f7" + ControlF8 = "c-f8" + ControlF9 = "c-f9" + ControlF10 = "c-f10" + ControlF11 = "c-f11" + ControlF12 = "c-f12" + ControlF13 = "c-f13" + ControlF14 = "c-f14" + ControlF15 = "c-f15" + ControlF16 = "c-f16" + ControlF17 = "c-f17" + ControlF18 = "c-f18" + ControlF19 = "c-f19" + ControlF20 = "c-f20" + ControlF21 = "c-f21" + ControlF22 = "c-f22" + ControlF23 = "c-f23" + ControlF24 = "c-f24" + + # Matches any key. + Any = "<any>" + + # Special. + ScrollUp = "<scroll-up>" + ScrollDown = "<scroll-down>" + + CPRResponse = "<cursor-position-response>" + Vt100MouseEvent = "<vt100-mouse-event>" + WindowsMouseEvent = "<windows-mouse-event>" + BracketedPaste = "<bracketed-paste>" + + SIGINT = "<sigint>" + + # For internal use: key which is ignored. + # (The key binding for this key should not do anything.) + Ignore = "<ignore>" + + # Some 'Key' aliases (for backwards-compatibility). + ControlSpace = ControlAt + Tab = ControlI + Enter = ControlM + Backspace = ControlH + + # ShiftControl was renamed to ControlShift in + # 888fcb6fa4efea0de8333177e1bbc792f3ff3c24 (20 Feb 2020). + ShiftControlLeft = ControlShiftLeft + ShiftControlRight = ControlShiftRight + ShiftControlHome = ControlShiftHome + ShiftControlEnd = ControlShiftEnd + + +ALL_KEYS: list[str] = [k.value for k in Keys] + + +# Aliases. +KEY_ALIASES: dict[str, str] = { + "backspace": "c-h", + "c-space": "c-@", + "enter": "c-m", + "tab": "c-i", + # ShiftControl was renamed to ControlShift. + "s-c-left": "c-s-left", + "s-c-right": "c-s-right", + "s-c-home": "c-s-home", + "s-c-end": "c-s-end", +} diff --git a/src/prompt_toolkit/layout/__init__.py b/src/prompt_toolkit/layout/__init__.py new file mode 100644 index 0000000..c5fce46 --- /dev/null +++ b/src/prompt_toolkit/layout/__init__.py @@ -0,0 +1,146 @@ +""" +Command line layout definitions +------------------------------- + +The layout of a command line interface is defined by a Container instance. +There are two main groups of classes here. Containers and controls: + +- A container can contain other containers or controls, it can have multiple + children and it decides about the dimensions. +- A control is responsible for rendering the actual content to a screen. + A control can propose some dimensions, but it's the container who decides + about the dimensions -- or when the control consumes more space -- which part + of the control will be visible. + + +Container classes:: + + - Container (Abstract base class) + |- HSplit (Horizontal split) + |- VSplit (Vertical split) + |- FloatContainer (Container which can also contain menus and other floats) + `- Window (Container which contains one actual control + +Control classes:: + + - UIControl (Abstract base class) + |- FormattedTextControl (Renders formatted text, or a simple list of text fragments) + `- BufferControl (Renders an input buffer.) + + +Usually, you end up wrapping every control inside a `Window` object, because +that's the only way to render it in a layout. + +There are some prepared toolbars which are ready to use:: + +- SystemToolbar (Shows the 'system' input buffer, for entering system commands.) +- ArgToolbar (Shows the input 'arg', for repetition of input commands.) +- SearchToolbar (Shows the 'search' input buffer, for incremental search.) +- CompletionsToolbar (Shows the completions of the current buffer.) +- ValidationToolbar (Shows validation errors of the current buffer.) + +And one prepared menu: + +- CompletionsMenu + +""" +from __future__ import annotations + +from .containers import ( + AnyContainer, + ColorColumn, + ConditionalContainer, + Container, + DynamicContainer, + Float, + FloatContainer, + HorizontalAlign, + HSplit, + ScrollOffsets, + VerticalAlign, + VSplit, + Window, + WindowAlign, + WindowRenderInfo, + is_container, + to_container, + to_window, +) +from .controls import ( + BufferControl, + DummyControl, + FormattedTextControl, + SearchBufferControl, + UIContent, + UIControl, +) +from .dimension import ( + AnyDimension, + D, + Dimension, + is_dimension, + max_layout_dimensions, + sum_layout_dimensions, + to_dimension, +) +from .layout import InvalidLayoutError, Layout, walk +from .margins import ( + ConditionalMargin, + Margin, + NumberedMargin, + PromptMargin, + ScrollbarMargin, +) +from .menus import CompletionsMenu, MultiColumnCompletionsMenu +from .scrollable_pane import ScrollablePane + +__all__ = [ + # Layout. + "Layout", + "InvalidLayoutError", + "walk", + # Dimensions. + "AnyDimension", + "Dimension", + "D", + "sum_layout_dimensions", + "max_layout_dimensions", + "to_dimension", + "is_dimension", + # Containers. + "AnyContainer", + "Container", + "HorizontalAlign", + "VerticalAlign", + "HSplit", + "VSplit", + "FloatContainer", + "Float", + "WindowAlign", + "Window", + "WindowRenderInfo", + "ConditionalContainer", + "ScrollOffsets", + "ColorColumn", + "to_container", + "to_window", + "is_container", + "DynamicContainer", + "ScrollablePane", + # Controls. + "BufferControl", + "SearchBufferControl", + "DummyControl", + "FormattedTextControl", + "UIControl", + "UIContent", + # Margins. + "Margin", + "NumberedMargin", + "ScrollbarMargin", + "ConditionalMargin", + "PromptMargin", + # Menus. + "CompletionsMenu", + "MultiColumnCompletionsMenu", +] diff --git a/src/prompt_toolkit/layout/containers.py b/src/prompt_toolkit/layout/containers.py new file mode 100644 index 0000000..100d4aa --- /dev/null +++ b/src/prompt_toolkit/layout/containers.py @@ -0,0 +1,2743 @@ +""" +Container for the layout. +(Containers can contain other containers or user interface controls.) +""" +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from enum import Enum +from functools import partial +from typing import TYPE_CHECKING, Callable, Sequence, Union, cast + +from prompt_toolkit.application.current import get_app +from prompt_toolkit.cache import SimpleCache +from prompt_toolkit.data_structures import Point +from prompt_toolkit.filters import ( + FilterOrBool, + emacs_insert_mode, + to_filter, + vi_insert_mode, +) +from prompt_toolkit.formatted_text import ( + AnyFormattedText, + StyleAndTextTuples, + to_formatted_text, +) +from prompt_toolkit.formatted_text.utils import ( + fragment_list_to_text, + fragment_list_width, +) +from prompt_toolkit.key_binding import KeyBindingsBase +from prompt_toolkit.mouse_events import MouseEvent, MouseEventType +from prompt_toolkit.utils import get_cwidth, take_using_weights, to_int, to_str + +from .controls import ( + DummyControl, + FormattedTextControl, + GetLinePrefixCallable, + UIContent, + UIControl, +) +from .dimension import ( + AnyDimension, + Dimension, + max_layout_dimensions, + sum_layout_dimensions, + to_dimension, +) +from .margins import Margin +from .mouse_handlers import MouseHandlers +from .screen import _CHAR_CACHE, Screen, WritePosition +from .utils import explode_text_fragments + +if TYPE_CHECKING: + from typing_extensions import Protocol, TypeGuard + + from prompt_toolkit.key_binding.key_bindings import NotImplementedOrNone + + +__all__ = [ + "AnyContainer", + "Container", + "HorizontalAlign", + "VerticalAlign", + "HSplit", + "VSplit", + "FloatContainer", + "Float", + "WindowAlign", + "Window", + "WindowRenderInfo", + "ConditionalContainer", + "ScrollOffsets", + "ColorColumn", + "to_container", + "to_window", + "is_container", + "DynamicContainer", +] + + +class Container(metaclass=ABCMeta): + """ + Base class for user interface layout. + """ + + @abstractmethod + def reset(self) -> None: + """ + Reset the state of this container and all the children. + (E.g. reset scroll offsets, etc...) + """ + + @abstractmethod + def preferred_width(self, max_available_width: int) -> Dimension: + """ + Return a :class:`~prompt_toolkit.layout.Dimension` that represents the + desired width for this container. + """ + + @abstractmethod + def preferred_height(self, width: int, max_available_height: int) -> Dimension: + """ + Return a :class:`~prompt_toolkit.layout.Dimension` that represents the + desired height for this container. + """ + + @abstractmethod + def write_to_screen( + self, + screen: Screen, + mouse_handlers: MouseHandlers, + write_position: WritePosition, + parent_style: str, + erase_bg: bool, + z_index: int | None, + ) -> None: + """ + Write the actual content to the screen. + + :param screen: :class:`~prompt_toolkit.layout.screen.Screen` + :param mouse_handlers: :class:`~prompt_toolkit.layout.mouse_handlers.MouseHandlers`. + :param parent_style: Style string to pass to the :class:`.Window` + object. This will be applied to all content of the windows. + :class:`.VSplit` and :class:`.HSplit` can use it to pass their + style down to the windows that they contain. + :param z_index: Used for propagating z_index from parent to child. + """ + + def is_modal(self) -> bool: + """ + When this container is modal, key bindings from parent containers are + not taken into account if a user control in this container is focused. + """ + return False + + def get_key_bindings(self) -> KeyBindingsBase | None: + """ + Returns a :class:`.KeyBindings` object. These bindings become active when any + user control in this container has the focus, except if any containers + between this container and the focused user control is modal. + """ + return None + + @abstractmethod + def get_children(self) -> list[Container]: + """ + Return the list of child :class:`.Container` objects. + """ + return [] + + +if TYPE_CHECKING: + + class MagicContainer(Protocol): + """ + Any object that implements ``__pt_container__`` represents a container. + """ + + def __pt_container__(self) -> AnyContainer: + ... + + +AnyContainer = Union[Container, "MagicContainer"] + + +def _window_too_small() -> Window: + "Create a `Window` that displays the 'Window too small' text." + return Window( + FormattedTextControl(text=[("class:window-too-small", " Window too small... ")]) + ) + + +class VerticalAlign(Enum): + "Alignment for `HSplit`." + + TOP = "TOP" + CENTER = "CENTER" + BOTTOM = "BOTTOM" + JUSTIFY = "JUSTIFY" + + +class HorizontalAlign(Enum): + "Alignment for `VSplit`." + + LEFT = "LEFT" + CENTER = "CENTER" + RIGHT = "RIGHT" + JUSTIFY = "JUSTIFY" + + +class _Split(Container): + """ + The common parts of `VSplit` and `HSplit`. + """ + + def __init__( + self, + children: Sequence[AnyContainer], + window_too_small: Container | None = None, + padding: AnyDimension = Dimension.exact(0), + padding_char: str | None = None, + padding_style: str = "", + width: AnyDimension = None, + height: AnyDimension = None, + z_index: int | None = None, + modal: bool = False, + key_bindings: KeyBindingsBase | None = None, + style: str | Callable[[], str] = "", + ) -> None: + self.children = [to_container(c) for c in children] + self.window_too_small = window_too_small or _window_too_small() + self.padding = padding + self.padding_char = padding_char + self.padding_style = padding_style + + self.width = width + self.height = height + self.z_index = z_index + + self.modal = modal + self.key_bindings = key_bindings + self.style = style + + def is_modal(self) -> bool: + return self.modal + + def get_key_bindings(self) -> KeyBindingsBase | None: + return self.key_bindings + + def get_children(self) -> list[Container]: + return self.children + + +class HSplit(_Split): + """ + Several layouts, one stacked above/under the other. :: + + +--------------------+ + | | + +--------------------+ + | | + +--------------------+ + + By default, this doesn't display a horizontal line between the children, + but if this is something you need, then create a HSplit as follows:: + + HSplit(children=[ ... ], padding_char='-', + padding=1, padding_style='#ffff00') + + :param children: List of child :class:`.Container` objects. + :param window_too_small: A :class:`.Container` object that is displayed if + there is not enough space for all the children. By default, this is a + "Window too small" message. + :param align: `VerticalAlign` value. + :param width: When given, use this width instead of looking at the children. + :param height: When given, use this height instead of looking at the children. + :param z_index: (int or None) When specified, this can be used to bring + element in front of floating elements. `None` means: inherit from parent. + :param style: A style string. + :param modal: ``True`` or ``False``. + :param key_bindings: ``None`` or a :class:`.KeyBindings` object. + + :param padding: (`Dimension` or int), size to be used for the padding. + :param padding_char: Character to be used for filling in the padding. + :param padding_style: Style to applied to the padding. + """ + + def __init__( + self, + children: Sequence[AnyContainer], + window_too_small: Container | None = None, + align: VerticalAlign = VerticalAlign.JUSTIFY, + padding: AnyDimension = 0, + padding_char: str | None = None, + padding_style: str = "", + width: AnyDimension = None, + height: AnyDimension = None, + z_index: int | None = None, + modal: bool = False, + key_bindings: KeyBindingsBase | None = None, + style: str | Callable[[], str] = "", + ) -> None: + super().__init__( + children=children, + window_too_small=window_too_small, + padding=padding, + padding_char=padding_char, + padding_style=padding_style, + width=width, + height=height, + z_index=z_index, + modal=modal, + key_bindings=key_bindings, + style=style, + ) + + self.align = align + + self._children_cache: SimpleCache[ + tuple[Container, ...], list[Container] + ] = SimpleCache(maxsize=1) + self._remaining_space_window = Window() # Dummy window. + + def preferred_width(self, max_available_width: int) -> Dimension: + if self.width is not None: + return to_dimension(self.width) + + if self.children: + dimensions = [c.preferred_width(max_available_width) for c in self.children] + return max_layout_dimensions(dimensions) + else: + return Dimension() + + def preferred_height(self, width: int, max_available_height: int) -> Dimension: + if self.height is not None: + return to_dimension(self.height) + + dimensions = [ + c.preferred_height(width, max_available_height) for c in self._all_children + ] + return sum_layout_dimensions(dimensions) + + def reset(self) -> None: + for c in self.children: + c.reset() + + @property + def _all_children(self) -> list[Container]: + """ + List of child objects, including padding. + """ + + def get() -> list[Container]: + result: list[Container] = [] + + # Padding Top. + if self.align in (VerticalAlign.CENTER, VerticalAlign.BOTTOM): + result.append(Window(width=Dimension(preferred=0))) + + # The children with padding. + for child in self.children: + result.append(child) + result.append( + Window( + height=self.padding, + char=self.padding_char, + style=self.padding_style, + ) + ) + if result: + result.pop() + + # Padding right. + if self.align in (VerticalAlign.CENTER, VerticalAlign.TOP): + result.append(Window(width=Dimension(preferred=0))) + + return result + + return self._children_cache.get(tuple(self.children), get) + + def write_to_screen( + self, + screen: Screen, + mouse_handlers: MouseHandlers, + write_position: WritePosition, + parent_style: str, + erase_bg: bool, + z_index: int | None, + ) -> None: + """ + Render the prompt to a `Screen` instance. + + :param screen: The :class:`~prompt_toolkit.layout.screen.Screen` class + to which the output has to be written. + """ + sizes = self._divide_heights(write_position) + style = parent_style + " " + to_str(self.style) + z_index = z_index if self.z_index is None else self.z_index + + if sizes is None: + self.window_too_small.write_to_screen( + screen, mouse_handlers, write_position, style, erase_bg, z_index + ) + else: + # + ypos = write_position.ypos + xpos = write_position.xpos + width = write_position.width + + # Draw child panes. + for s, c in zip(sizes, self._all_children): + c.write_to_screen( + screen, + mouse_handlers, + WritePosition(xpos, ypos, width, s), + style, + erase_bg, + z_index, + ) + ypos += s + + # Fill in the remaining space. This happens when a child control + # refuses to take more space and we don't have any padding. Adding a + # dummy child control for this (in `self._all_children`) is not + # desired, because in some situations, it would take more space, even + # when it's not required. This is required to apply the styling. + remaining_height = write_position.ypos + write_position.height - ypos + if remaining_height > 0: + self._remaining_space_window.write_to_screen( + screen, + mouse_handlers, + WritePosition(xpos, ypos, width, remaining_height), + style, + erase_bg, + z_index, + ) + + def _divide_heights(self, write_position: WritePosition) -> list[int] | None: + """ + Return the heights for all rows. + Or None when there is not enough space. + """ + if not self.children: + return [] + + width = write_position.width + height = write_position.height + + # Calculate heights. + dimensions = [c.preferred_height(width, height) for c in self._all_children] + + # Sum dimensions + sum_dimensions = sum_layout_dimensions(dimensions) + + # If there is not enough space for both. + # Don't do anything. + if sum_dimensions.min > height: + return None + + # Find optimal sizes. (Start with minimal size, increase until we cover + # the whole height.) + sizes = [d.min for d in dimensions] + + child_generator = take_using_weights( + items=list(range(len(dimensions))), weights=[d.weight for d in dimensions] + ) + + i = next(child_generator) + + # Increase until we meet at least the 'preferred' size. + preferred_stop = min(height, sum_dimensions.preferred) + preferred_dimensions = [d.preferred for d in dimensions] + + while sum(sizes) < preferred_stop: + if sizes[i] < preferred_dimensions[i]: + sizes[i] += 1 + i = next(child_generator) + + # Increase until we use all the available space. (or until "max") + if not get_app().is_done: + max_stop = min(height, sum_dimensions.max) + max_dimensions = [d.max for d in dimensions] + + while sum(sizes) < max_stop: + if sizes[i] < max_dimensions[i]: + sizes[i] += 1 + i = next(child_generator) + + return sizes + + +class VSplit(_Split): + """ + Several layouts, one stacked left/right of the other. :: + + +---------+----------+ + | | | + | | | + +---------+----------+ + + By default, this doesn't display a vertical line between the children, but + if this is something you need, then create a HSplit as follows:: + + VSplit(children=[ ... ], padding_char='|', + padding=1, padding_style='#ffff00') + + :param children: List of child :class:`.Container` objects. + :param window_too_small: A :class:`.Container` object that is displayed if + there is not enough space for all the children. By default, this is a + "Window too small" message. + :param align: `HorizontalAlign` value. + :param width: When given, use this width instead of looking at the children. + :param height: When given, use this height instead of looking at the children. + :param z_index: (int or None) When specified, this can be used to bring + element in front of floating elements. `None` means: inherit from parent. + :param style: A style string. + :param modal: ``True`` or ``False``. + :param key_bindings: ``None`` or a :class:`.KeyBindings` object. + + :param padding: (`Dimension` or int), size to be used for the padding. + :param padding_char: Character to be used for filling in the padding. + :param padding_style: Style to applied to the padding. + """ + + def __init__( + self, + children: Sequence[AnyContainer], + window_too_small: Container | None = None, + align: HorizontalAlign = HorizontalAlign.JUSTIFY, + padding: AnyDimension = 0, + padding_char: str | None = None, + padding_style: str = "", + width: AnyDimension = None, + height: AnyDimension = None, + z_index: int | None = None, + modal: bool = False, + key_bindings: KeyBindingsBase | None = None, + style: str | Callable[[], str] = "", + ) -> None: + super().__init__( + children=children, + window_too_small=window_too_small, + padding=padding, + padding_char=padding_char, + padding_style=padding_style, + width=width, + height=height, + z_index=z_index, + modal=modal, + key_bindings=key_bindings, + style=style, + ) + + self.align = align + + self._children_cache: SimpleCache[ + tuple[Container, ...], list[Container] + ] = SimpleCache(maxsize=1) + self._remaining_space_window = Window() # Dummy window. + + def preferred_width(self, max_available_width: int) -> Dimension: + if self.width is not None: + return to_dimension(self.width) + + dimensions = [ + c.preferred_width(max_available_width) for c in self._all_children + ] + + return sum_layout_dimensions(dimensions) + + def preferred_height(self, width: int, max_available_height: int) -> Dimension: + if self.height is not None: + return to_dimension(self.height) + + # At the point where we want to calculate the heights, the widths have + # already been decided. So we can trust `width` to be the actual + # `width` that's going to be used for the rendering. So, + # `divide_widths` is supposed to use all of the available width. + # Using only the `preferred` width caused a bug where the reported + # height was more than required. (we had a `BufferControl` which did + # wrap lines because of the smaller width returned by `_divide_widths`. + + sizes = self._divide_widths(width) + children = self._all_children + + if sizes is None: + return Dimension() + else: + dimensions = [ + c.preferred_height(s, max_available_height) + for s, c in zip(sizes, children) + ] + return max_layout_dimensions(dimensions) + + def reset(self) -> None: + for c in self.children: + c.reset() + + @property + def _all_children(self) -> list[Container]: + """ + List of child objects, including padding. + """ + + def get() -> list[Container]: + result: list[Container] = [] + + # Padding left. + if self.align in (HorizontalAlign.CENTER, HorizontalAlign.RIGHT): + result.append(Window(width=Dimension(preferred=0))) + + # The children with padding. + for child in self.children: + result.append(child) + result.append( + Window( + width=self.padding, + char=self.padding_char, + style=self.padding_style, + ) + ) + if result: + result.pop() + + # Padding right. + if self.align in (HorizontalAlign.CENTER, HorizontalAlign.LEFT): + result.append(Window(width=Dimension(preferred=0))) + + return result + + return self._children_cache.get(tuple(self.children), get) + + def _divide_widths(self, width: int) -> list[int] | None: + """ + Return the widths for all columns. + Or None when there is not enough space. + """ + children = self._all_children + + if not children: + return [] + + # Calculate widths. + dimensions = [c.preferred_width(width) for c in children] + preferred_dimensions = [d.preferred for d in dimensions] + + # Sum dimensions + sum_dimensions = sum_layout_dimensions(dimensions) + + # If there is not enough space for both. + # Don't do anything. + if sum_dimensions.min > width: + return None + + # Find optimal sizes. (Start with minimal size, increase until we cover + # the whole width.) + sizes = [d.min for d in dimensions] + + child_generator = take_using_weights( + items=list(range(len(dimensions))), weights=[d.weight for d in dimensions] + ) + + i = next(child_generator) + + # Increase until we meet at least the 'preferred' size. + preferred_stop = min(width, sum_dimensions.preferred) + + while sum(sizes) < preferred_stop: + if sizes[i] < preferred_dimensions[i]: + sizes[i] += 1 + i = next(child_generator) + + # Increase until we use all the available space. + max_dimensions = [d.max for d in dimensions] + max_stop = min(width, sum_dimensions.max) + + while sum(sizes) < max_stop: + if sizes[i] < max_dimensions[i]: + sizes[i] += 1 + i = next(child_generator) + + return sizes + + def write_to_screen( + self, + screen: Screen, + mouse_handlers: MouseHandlers, + write_position: WritePosition, + parent_style: str, + erase_bg: bool, + z_index: int | None, + ) -> None: + """ + Render the prompt to a `Screen` instance. + + :param screen: The :class:`~prompt_toolkit.layout.screen.Screen` class + to which the output has to be written. + """ + if not self.children: + return + + children = self._all_children + sizes = self._divide_widths(write_position.width) + style = parent_style + " " + to_str(self.style) + z_index = z_index if self.z_index is None else self.z_index + + # If there is not enough space. + if sizes is None: + self.window_too_small.write_to_screen( + screen, mouse_handlers, write_position, style, erase_bg, z_index + ) + return + + # Calculate heights, take the largest possible, but not larger than + # write_position.height. + heights = [ + child.preferred_height(width, write_position.height).preferred + for width, child in zip(sizes, children) + ] + height = max(write_position.height, min(write_position.height, max(heights))) + + # + ypos = write_position.ypos + xpos = write_position.xpos + + # Draw all child panes. + for s, c in zip(sizes, children): + c.write_to_screen( + screen, + mouse_handlers, + WritePosition(xpos, ypos, s, height), + style, + erase_bg, + z_index, + ) + xpos += s + + # Fill in the remaining space. This happens when a child control + # refuses to take more space and we don't have any padding. Adding a + # dummy child control for this (in `self._all_children`) is not + # desired, because in some situations, it would take more space, even + # when it's not required. This is required to apply the styling. + remaining_width = write_position.xpos + write_position.width - xpos + if remaining_width > 0: + self._remaining_space_window.write_to_screen( + screen, + mouse_handlers, + WritePosition(xpos, ypos, remaining_width, height), + style, + erase_bg, + z_index, + ) + + +class FloatContainer(Container): + """ + Container which can contain another container for the background, as well + as a list of floating containers on top of it. + + Example Usage:: + + FloatContainer(content=Window(...), + floats=[ + Float(xcursor=True, + ycursor=True, + content=CompletionsMenu(...)) + ]) + + :param z_index: (int or None) When specified, this can be used to bring + element in front of floating elements. `None` means: inherit from parent. + This is the z_index for the whole `Float` container as a whole. + """ + + def __init__( + self, + content: AnyContainer, + floats: list[Float], + modal: bool = False, + key_bindings: KeyBindingsBase | None = None, + style: str | Callable[[], str] = "", + z_index: int | None = None, + ) -> None: + self.content = to_container(content) + self.floats = floats + + self.modal = modal + self.key_bindings = key_bindings + self.style = style + self.z_index = z_index + + def reset(self) -> None: + self.content.reset() + + for f in self.floats: + f.content.reset() + + def preferred_width(self, max_available_width: int) -> Dimension: + return self.content.preferred_width(max_available_width) + + def preferred_height(self, width: int, max_available_height: int) -> Dimension: + """ + Return the preferred height of the float container. + (We don't care about the height of the floats, they should always fit + into the dimensions provided by the container.) + """ + return self.content.preferred_height(width, max_available_height) + + def write_to_screen( + self, + screen: Screen, + mouse_handlers: MouseHandlers, + write_position: WritePosition, + parent_style: str, + erase_bg: bool, + z_index: int | None, + ) -> None: + style = parent_style + " " + to_str(self.style) + z_index = z_index if self.z_index is None else self.z_index + + self.content.write_to_screen( + screen, mouse_handlers, write_position, style, erase_bg, z_index + ) + + for number, fl in enumerate(self.floats): + # z_index of a Float is computed by summing the z_index of the + # container and the `Float`. + new_z_index = (z_index or 0) + fl.z_index + style = parent_style + " " + to_str(self.style) + + # If the float that we have here, is positioned relative to the + # cursor position, but the Window that specifies the cursor + # position is not drawn yet, because it's a Float itself, we have + # to postpone this calculation. (This is a work-around, but good + # enough for now.) + postpone = fl.xcursor is not None or fl.ycursor is not None + + if postpone: + new_z_index = ( + number + 10**8 + ) # Draw as late as possible, but keep the order. + screen.draw_with_z_index( + z_index=new_z_index, + draw_func=partial( + self._draw_float, + fl, + screen, + mouse_handlers, + write_position, + style, + erase_bg, + new_z_index, + ), + ) + else: + self._draw_float( + fl, + screen, + mouse_handlers, + write_position, + style, + erase_bg, + new_z_index, + ) + + def _draw_float( + self, + fl: Float, + screen: Screen, + mouse_handlers: MouseHandlers, + write_position: WritePosition, + style: str, + erase_bg: bool, + z_index: int | None, + ) -> None: + "Draw a single Float." + # When a menu_position was given, use this instead of the cursor + # position. (These cursor positions are absolute, translate again + # relative to the write_position.) + # Note: This should be inside the for-loop, because one float could + # set the cursor position to be used for the next one. + cpos = screen.get_menu_position( + fl.attach_to_window or get_app().layout.current_window + ) + cursor_position = Point( + x=cpos.x - write_position.xpos, y=cpos.y - write_position.ypos + ) + + fl_width = fl.get_width() + fl_height = fl.get_height() + width: int + height: int + xpos: int + ypos: int + + # Left & width given. + if fl.left is not None and fl_width is not None: + xpos = fl.left + width = fl_width + # Left & right given -> calculate width. + elif fl.left is not None and fl.right is not None: + xpos = fl.left + width = write_position.width - fl.left - fl.right + # Width & right given -> calculate left. + elif fl_width is not None and fl.right is not None: + xpos = write_position.width - fl.right - fl_width + width = fl_width + # Near x position of cursor. + elif fl.xcursor: + if fl_width is None: + width = fl.content.preferred_width(write_position.width).preferred + width = min(write_position.width, width) + else: + width = fl_width + + xpos = cursor_position.x + if xpos + width > write_position.width: + xpos = max(0, write_position.width - width) + # Only width given -> center horizontally. + elif fl_width: + xpos = int((write_position.width - fl_width) / 2) + width = fl_width + # Otherwise, take preferred width from float content. + else: + width = fl.content.preferred_width(write_position.width).preferred + + if fl.left is not None: + xpos = fl.left + elif fl.right is not None: + xpos = max(0, write_position.width - width - fl.right) + else: # Center horizontally. + xpos = max(0, int((write_position.width - width) / 2)) + + # Trim. + width = min(width, write_position.width - xpos) + + # Top & height given. + if fl.top is not None and fl_height is not None: + ypos = fl.top + height = fl_height + # Top & bottom given -> calculate height. + elif fl.top is not None and fl.bottom is not None: + ypos = fl.top + height = write_position.height - fl.top - fl.bottom + # Height & bottom given -> calculate top. + elif fl_height is not None and fl.bottom is not None: + ypos = write_position.height - fl_height - fl.bottom + height = fl_height + # Near cursor. + elif fl.ycursor: + ypos = cursor_position.y + (0 if fl.allow_cover_cursor else 1) + + if fl_height is None: + height = fl.content.preferred_height( + width, write_position.height + ).preferred + else: + height = fl_height + + # Reduce height if not enough space. (We can use the height + # when the content requires it.) + if height > write_position.height - ypos: + if write_position.height - ypos + 1 >= ypos: + # When the space below the cursor is more than + # the space above, just reduce the height. + height = write_position.height - ypos + else: + # Otherwise, fit the float above the cursor. + height = min(height, cursor_position.y) + ypos = cursor_position.y - height + + # Only height given -> center vertically. + elif fl_height: + ypos = int((write_position.height - fl_height) / 2) + height = fl_height + # Otherwise, take preferred height from content. + else: + height = fl.content.preferred_height(width, write_position.height).preferred + + if fl.top is not None: + ypos = fl.top + elif fl.bottom is not None: + ypos = max(0, write_position.height - height - fl.bottom) + else: # Center vertically. + ypos = max(0, int((write_position.height - height) / 2)) + + # Trim. + height = min(height, write_position.height - ypos) + + # Write float. + # (xpos and ypos can be negative: a float can be partially visible.) + if height > 0 and width > 0: + wp = WritePosition( + xpos=xpos + write_position.xpos, + ypos=ypos + write_position.ypos, + width=width, + height=height, + ) + + if not fl.hide_when_covering_content or self._area_is_empty(screen, wp): + fl.content.write_to_screen( + screen, + mouse_handlers, + wp, + style, + erase_bg=not fl.transparent(), + z_index=z_index, + ) + + def _area_is_empty(self, screen: Screen, write_position: WritePosition) -> bool: + """ + Return True when the area below the write position is still empty. + (For floats that should not hide content underneath.) + """ + wp = write_position + + for y in range(wp.ypos, wp.ypos + wp.height): + if y in screen.data_buffer: + row = screen.data_buffer[y] + + for x in range(wp.xpos, wp.xpos + wp.width): + c = row[x] + if c.char != " ": + return False + + return True + + def is_modal(self) -> bool: + return self.modal + + def get_key_bindings(self) -> KeyBindingsBase | None: + return self.key_bindings + + def get_children(self) -> list[Container]: + children = [self.content] + children.extend(f.content for f in self.floats) + return children + + +class Float: + """ + Float for use in a :class:`.FloatContainer`. + Except for the `content` parameter, all other options are optional. + + :param content: :class:`.Container` instance. + + :param width: :class:`.Dimension` or callable which returns a :class:`.Dimension`. + :param height: :class:`.Dimension` or callable which returns a :class:`.Dimension`. + + :param left: Distance to the left edge of the :class:`.FloatContainer`. + :param right: Distance to the right edge of the :class:`.FloatContainer`. + :param top: Distance to the top of the :class:`.FloatContainer`. + :param bottom: Distance to the bottom of the :class:`.FloatContainer`. + + :param attach_to_window: Attach to the cursor from this window, instead of + the current window. + :param hide_when_covering_content: Hide the float when it covers content underneath. + :param allow_cover_cursor: When `False`, make sure to display the float + below the cursor. Not on top of the indicated position. + :param z_index: Z-index position. For a Float, this needs to be at least + one. It is relative to the z_index of the parent container. + :param transparent: :class:`.Filter` indicating whether this float needs to be + drawn transparently. + """ + + def __init__( + self, + content: AnyContainer, + top: int | None = None, + right: int | None = None, + bottom: int | None = None, + left: int | None = None, + width: int | Callable[[], int] | None = None, + height: int | Callable[[], int] | None = None, + xcursor: bool = False, + ycursor: bool = False, + attach_to_window: AnyContainer | None = None, + hide_when_covering_content: bool = False, + allow_cover_cursor: bool = False, + z_index: int = 1, + transparent: bool = False, + ) -> None: + assert z_index >= 1 + + self.left = left + self.right = right + self.top = top + self.bottom = bottom + + self.width = width + self.height = height + + self.xcursor = xcursor + self.ycursor = ycursor + + self.attach_to_window = ( + to_window(attach_to_window) if attach_to_window else None + ) + + self.content = to_container(content) + self.hide_when_covering_content = hide_when_covering_content + self.allow_cover_cursor = allow_cover_cursor + self.z_index = z_index + self.transparent = to_filter(transparent) + + def get_width(self) -> int | None: + if callable(self.width): + return self.width() + return self.width + + def get_height(self) -> int | None: + if callable(self.height): + return self.height() + return self.height + + def __repr__(self) -> str: + return "Float(content=%r)" % self.content + + +class WindowRenderInfo: + """ + Render information for the last render time of this control. + It stores mapping information between the input buffers (in case of a + :class:`~prompt_toolkit.layout.controls.BufferControl`) and the actual + render position on the output screen. + + (Could be used for implementation of the Vi 'H' and 'L' key bindings as + well as implementing mouse support.) + + :param ui_content: The original :class:`.UIContent` instance that contains + the whole input, without clipping. (ui_content) + :param horizontal_scroll: The horizontal scroll of the :class:`.Window` instance. + :param vertical_scroll: The vertical scroll of the :class:`.Window` instance. + :param window_width: The width of the window that displays the content, + without the margins. + :param window_height: The height of the window that displays the content. + :param configured_scroll_offsets: The scroll offsets as configured for the + :class:`Window` instance. + :param visible_line_to_row_col: Mapping that maps the row numbers on the + displayed screen (starting from zero for the first visible line) to + (row, col) tuples pointing to the row and column of the :class:`.UIContent`. + :param rowcol_to_yx: Mapping that maps (row, column) tuples representing + coordinates of the :class:`UIContent` to (y, x) absolute coordinates at + the rendered screen. + """ + + def __init__( + self, + window: Window, + ui_content: UIContent, + horizontal_scroll: int, + vertical_scroll: int, + window_width: int, + window_height: int, + configured_scroll_offsets: ScrollOffsets, + visible_line_to_row_col: dict[int, tuple[int, int]], + rowcol_to_yx: dict[tuple[int, int], tuple[int, int]], + x_offset: int, + y_offset: int, + wrap_lines: bool, + ) -> None: + self.window = window + self.ui_content = ui_content + self.vertical_scroll = vertical_scroll + self.window_width = window_width # Width without margins. + self.window_height = window_height + + self.configured_scroll_offsets = configured_scroll_offsets + self.visible_line_to_row_col = visible_line_to_row_col + self.wrap_lines = wrap_lines + + self._rowcol_to_yx = rowcol_to_yx # row/col from input to absolute y/x + # screen coordinates. + self._x_offset = x_offset + self._y_offset = y_offset + + @property + def visible_line_to_input_line(self) -> dict[int, int]: + return { + visible_line: rowcol[0] + for visible_line, rowcol in self.visible_line_to_row_col.items() + } + + @property + def cursor_position(self) -> Point: + """ + Return the cursor position coordinates, relative to the left/top corner + of the rendered screen. + """ + cpos = self.ui_content.cursor_position + try: + y, x = self._rowcol_to_yx[cpos.y, cpos.x] + except KeyError: + # For `DummyControl` for instance, the content can be empty, and so + # will `_rowcol_to_yx` be. Return 0/0 by default. + return Point(x=0, y=0) + else: + return Point(x=x - self._x_offset, y=y - self._y_offset) + + @property + def applied_scroll_offsets(self) -> ScrollOffsets: + """ + Return a :class:`.ScrollOffsets` instance that indicates the actual + offset. This can be less than or equal to what's configured. E.g, when + the cursor is completely at the top, the top offset will be zero rather + than what's configured. + """ + if self.displayed_lines[0] == 0: + top = 0 + else: + # Get row where the cursor is displayed. + y = self.input_line_to_visible_line[self.ui_content.cursor_position.y] + top = min(y, self.configured_scroll_offsets.top) + + return ScrollOffsets( + top=top, + bottom=min( + self.ui_content.line_count - self.displayed_lines[-1] - 1, + self.configured_scroll_offsets.bottom, + ), + # For left/right, it probably doesn't make sense to return something. + # (We would have to calculate the widths of all the lines and keep + # double width characters in mind.) + left=0, + right=0, + ) + + @property + def displayed_lines(self) -> list[int]: + """ + List of all the visible rows. (Line numbers of the input buffer.) + The last line may not be entirely visible. + """ + return sorted(row for row, col in self.visible_line_to_row_col.values()) + + @property + def input_line_to_visible_line(self) -> dict[int, int]: + """ + Return the dictionary mapping the line numbers of the input buffer to + the lines of the screen. When a line spans several rows at the screen, + the first row appears in the dictionary. + """ + result: dict[int, int] = {} + for k, v in self.visible_line_to_input_line.items(): + if v in result: + result[v] = min(result[v], k) + else: + result[v] = k + return result + + def first_visible_line(self, after_scroll_offset: bool = False) -> int: + """ + Return the line number (0 based) of the input document that corresponds + with the first visible line. + """ + if after_scroll_offset: + return self.displayed_lines[self.applied_scroll_offsets.top] + else: + return self.displayed_lines[0] + + def last_visible_line(self, before_scroll_offset: bool = False) -> int: + """ + Like `first_visible_line`, but for the last visible line. + """ + if before_scroll_offset: + return self.displayed_lines[-1 - self.applied_scroll_offsets.bottom] + else: + return self.displayed_lines[-1] + + def center_visible_line( + self, before_scroll_offset: bool = False, after_scroll_offset: bool = False + ) -> int: + """ + Like `first_visible_line`, but for the center visible line. + """ + return ( + self.first_visible_line(after_scroll_offset) + + ( + self.last_visible_line(before_scroll_offset) + - self.first_visible_line(after_scroll_offset) + ) + // 2 + ) + + @property + def content_height(self) -> int: + """ + The full height of the user control. + """ + return self.ui_content.line_count + + @property + def full_height_visible(self) -> bool: + """ + True when the full height is visible (There is no vertical scroll.) + """ + return ( + self.vertical_scroll == 0 + and self.last_visible_line() == self.content_height + ) + + @property + def top_visible(self) -> bool: + """ + True when the top of the buffer is visible. + """ + return self.vertical_scroll == 0 + + @property + def bottom_visible(self) -> bool: + """ + True when the bottom of the buffer is visible. + """ + return self.last_visible_line() == self.content_height - 1 + + @property + def vertical_scroll_percentage(self) -> int: + """ + Vertical scroll as a percentage. (0 means: the top is visible, + 100 means: the bottom is visible.) + """ + if self.bottom_visible: + return 100 + else: + return 100 * self.vertical_scroll // self.content_height + + def get_height_for_line(self, lineno: int) -> int: + """ + Return the height of the given line. + (The height that it would take, if this line became visible.) + """ + if self.wrap_lines: + return self.ui_content.get_height_for_line( + lineno, self.window_width, self.window.get_line_prefix + ) + else: + return 1 + + +class ScrollOffsets: + """ + Scroll offsets for the :class:`.Window` class. + + Note that left/right offsets only make sense if line wrapping is disabled. + """ + + def __init__( + self, + top: int | Callable[[], int] = 0, + bottom: int | Callable[[], int] = 0, + left: int | Callable[[], int] = 0, + right: int | Callable[[], int] = 0, + ) -> None: + self._top = top + self._bottom = bottom + self._left = left + self._right = right + + @property + def top(self) -> int: + return to_int(self._top) + + @property + def bottom(self) -> int: + return to_int(self._bottom) + + @property + def left(self) -> int: + return to_int(self._left) + + @property + def right(self) -> int: + return to_int(self._right) + + def __repr__(self) -> str: + return "ScrollOffsets(top={!r}, bottom={!r}, left={!r}, right={!r})".format( + self._top, + self._bottom, + self._left, + self._right, + ) + + +class ColorColumn: + """ + Column for a :class:`.Window` to be colored. + """ + + def __init__(self, position: int, style: str = "class:color-column") -> None: + self.position = position + self.style = style + + +_in_insert_mode = vi_insert_mode | emacs_insert_mode + + +class WindowAlign(Enum): + """ + Alignment of the Window content. + + Note that this is different from `HorizontalAlign` and `VerticalAlign`, + which are used for the alignment of the child containers in respectively + `VSplit` and `HSplit`. + """ + + LEFT = "LEFT" + RIGHT = "RIGHT" + CENTER = "CENTER" + + +class Window(Container): + """ + Container that holds a control. + + :param content: :class:`.UIControl` instance. + :param width: :class:`.Dimension` instance or callable. + :param height: :class:`.Dimension` instance or callable. + :param z_index: When specified, this can be used to bring element in front + of floating elements. + :param dont_extend_width: When `True`, don't take up more width then the + preferred width reported by the control. + :param dont_extend_height: When `True`, don't take up more width then the + preferred height reported by the control. + :param ignore_content_width: A `bool` or :class:`.Filter` instance. Ignore + the :class:`.UIContent` width when calculating the dimensions. + :param ignore_content_height: A `bool` or :class:`.Filter` instance. Ignore + the :class:`.UIContent` height when calculating the dimensions. + :param left_margins: A list of :class:`.Margin` instance to be displayed on + the left. For instance: :class:`~prompt_toolkit.layout.NumberedMargin` + can be one of them in order to show line numbers. + :param right_margins: Like `left_margins`, but on the other side. + :param scroll_offsets: :class:`.ScrollOffsets` instance, representing the + preferred amount of lines/columns to be always visible before/after the + cursor. When both top and bottom are a very high number, the cursor + will be centered vertically most of the time. + :param allow_scroll_beyond_bottom: A `bool` or + :class:`.Filter` instance. When True, allow scrolling so far, that the + top part of the content is not visible anymore, while there is still + empty space available at the bottom of the window. In the Vi editor for + instance, this is possible. You will see tildes while the top part of + the body is hidden. + :param wrap_lines: A `bool` or :class:`.Filter` instance. When True, don't + scroll horizontally, but wrap lines instead. + :param get_vertical_scroll: Callable that takes this window + instance as input and returns a preferred vertical scroll. + (When this is `None`, the scroll is only determined by the last and + current cursor position.) + :param get_horizontal_scroll: Callable that takes this window + instance as input and returns a preferred vertical scroll. + :param always_hide_cursor: A `bool` or + :class:`.Filter` instance. When True, never display the cursor, even + when the user control specifies a cursor position. + :param cursorline: A `bool` or :class:`.Filter` instance. When True, + display a cursorline. + :param cursorcolumn: A `bool` or :class:`.Filter` instance. When True, + display a cursorcolumn. + :param colorcolumns: A list of :class:`.ColorColumn` instances that + describe the columns to be highlighted, or a callable that returns such + a list. + :param align: :class:`.WindowAlign` value or callable that returns an + :class:`.WindowAlign` value. alignment of content. + :param style: A style string. Style to be applied to all the cells in this + window. (This can be a callable that returns a string.) + :param char: (string) Character to be used for filling the background. This + can also be a callable that returns a character. + :param get_line_prefix: None or a callable that returns formatted text to + be inserted before a line. It takes a line number (int) and a + wrap_count and returns formatted text. This can be used for + implementation of line continuations, things like Vim "breakindent" and + so on. + """ + + def __init__( + self, + content: UIControl | None = None, + width: AnyDimension = None, + height: AnyDimension = None, + z_index: int | None = None, + dont_extend_width: FilterOrBool = False, + dont_extend_height: FilterOrBool = False, + ignore_content_width: FilterOrBool = False, + ignore_content_height: FilterOrBool = False, + left_margins: Sequence[Margin] | None = None, + right_margins: Sequence[Margin] | None = None, + scroll_offsets: ScrollOffsets | None = None, + allow_scroll_beyond_bottom: FilterOrBool = False, + wrap_lines: FilterOrBool = False, + get_vertical_scroll: Callable[[Window], int] | None = None, + get_horizontal_scroll: Callable[[Window], int] | None = None, + always_hide_cursor: FilterOrBool = False, + cursorline: FilterOrBool = False, + cursorcolumn: FilterOrBool = False, + colorcolumns: ( + None | list[ColorColumn] | Callable[[], list[ColorColumn]] + ) = None, + align: WindowAlign | Callable[[], WindowAlign] = WindowAlign.LEFT, + style: str | Callable[[], str] = "", + char: None | str | Callable[[], str] = None, + get_line_prefix: GetLinePrefixCallable | None = None, + ) -> None: + self.allow_scroll_beyond_bottom = to_filter(allow_scroll_beyond_bottom) + self.always_hide_cursor = to_filter(always_hide_cursor) + self.wrap_lines = to_filter(wrap_lines) + self.cursorline = to_filter(cursorline) + self.cursorcolumn = to_filter(cursorcolumn) + + self.content = content or DummyControl() + self.dont_extend_width = to_filter(dont_extend_width) + self.dont_extend_height = to_filter(dont_extend_height) + self.ignore_content_width = to_filter(ignore_content_width) + self.ignore_content_height = to_filter(ignore_content_height) + self.left_margins = left_margins or [] + self.right_margins = right_margins or [] + self.scroll_offsets = scroll_offsets or ScrollOffsets() + self.get_vertical_scroll = get_vertical_scroll + self.get_horizontal_scroll = get_horizontal_scroll + self.colorcolumns = colorcolumns or [] + self.align = align + self.style = style + self.char = char + self.get_line_prefix = get_line_prefix + + self.width = width + self.height = height + self.z_index = z_index + + # Cache for the screens generated by the margin. + self._ui_content_cache: SimpleCache[ + tuple[int, int, int], UIContent + ] = SimpleCache(maxsize=8) + self._margin_width_cache: SimpleCache[tuple[Margin, int], int] = SimpleCache( + maxsize=1 + ) + + self.reset() + + def __repr__(self) -> str: + return "Window(content=%r)" % self.content + + def reset(self) -> None: + self.content.reset() + + #: Scrolling position of the main content. + self.vertical_scroll = 0 + self.horizontal_scroll = 0 + + # Vertical scroll 2: this is the vertical offset that a line is + # scrolled if a single line (the one that contains the cursor) consumes + # all of the vertical space. + self.vertical_scroll_2 = 0 + + #: Keep render information (mappings between buffer input and render + #: output.) + self.render_info: WindowRenderInfo | None = None + + def _get_margin_width(self, margin: Margin) -> int: + """ + Return the width for this margin. + (Calculate only once per render time.) + """ + + # Margin.get_width, needs to have a UIContent instance. + def get_ui_content() -> UIContent: + return self._get_ui_content(width=0, height=0) + + def get_width() -> int: + return margin.get_width(get_ui_content) + + key = (margin, get_app().render_counter) + return self._margin_width_cache.get(key, get_width) + + def _get_total_margin_width(self) -> int: + """ + Calculate and return the width of the margin (left + right). + """ + return sum(self._get_margin_width(m) for m in self.left_margins) + sum( + self._get_margin_width(m) for m in self.right_margins + ) + + def preferred_width(self, max_available_width: int) -> Dimension: + """ + Calculate the preferred width for this window. + """ + + def preferred_content_width() -> int | None: + """Content width: is only calculated if no exact width for the + window was given.""" + if self.ignore_content_width(): + return None + + # Calculate the width of the margin. + total_margin_width = self._get_total_margin_width() + + # Window of the content. (Can be `None`.) + preferred_width = self.content.preferred_width( + max_available_width - total_margin_width + ) + + if preferred_width is not None: + # Include width of the margins. + preferred_width += total_margin_width + return preferred_width + + # Merge. + return self._merge_dimensions( + dimension=to_dimension(self.width), + get_preferred=preferred_content_width, + dont_extend=self.dont_extend_width(), + ) + + def preferred_height(self, width: int, max_available_height: int) -> Dimension: + """ + Calculate the preferred height for this window. + """ + + def preferred_content_height() -> int | None: + """Content height: is only calculated if no exact height for the + window was given.""" + if self.ignore_content_height(): + return None + + total_margin_width = self._get_total_margin_width() + wrap_lines = self.wrap_lines() + + return self.content.preferred_height( + width - total_margin_width, + max_available_height, + wrap_lines, + self.get_line_prefix, + ) + + return self._merge_dimensions( + dimension=to_dimension(self.height), + get_preferred=preferred_content_height, + dont_extend=self.dont_extend_height(), + ) + + @staticmethod + def _merge_dimensions( + dimension: Dimension | None, + get_preferred: Callable[[], int | None], + dont_extend: bool = False, + ) -> Dimension: + """ + Take the Dimension from this `Window` class and the received preferred + size from the `UIControl` and return a `Dimension` to report to the + parent container. + """ + dimension = dimension or Dimension() + + # When a preferred dimension was explicitly given to the Window, + # ignore the UIControl. + preferred: int | None + + if dimension.preferred_specified: + preferred = dimension.preferred + else: + # Otherwise, calculate the preferred dimension from the UI control + # content. + preferred = get_preferred() + + # When a 'preferred' dimension is given by the UIControl, make sure + # that it stays within the bounds of the Window. + if preferred is not None: + if dimension.max_specified: + preferred = min(preferred, dimension.max) + + if dimension.min_specified: + preferred = max(preferred, dimension.min) + + # When a `dont_extend` flag has been given, use the preferred dimension + # also as the max dimension. + max_: int | None + min_: int | None + + if dont_extend and preferred is not None: + max_ = min(dimension.max, preferred) + else: + max_ = dimension.max if dimension.max_specified else None + + min_ = dimension.min if dimension.min_specified else None + + return Dimension( + min=min_, max=max_, preferred=preferred, weight=dimension.weight + ) + + def _get_ui_content(self, width: int, height: int) -> UIContent: + """ + Create a `UIContent` instance. + """ + + def get_content() -> UIContent: + return self.content.create_content(width=width, height=height) + + key = (get_app().render_counter, width, height) + return self._ui_content_cache.get(key, get_content) + + def _get_digraph_char(self) -> str | None: + "Return `False`, or the Digraph symbol to be used." + app = get_app() + if app.quoted_insert: + return "^" + if app.vi_state.waiting_for_digraph: + if app.vi_state.digraph_symbol1: + return app.vi_state.digraph_symbol1 + return "?" + return None + + def write_to_screen( + self, + screen: Screen, + mouse_handlers: MouseHandlers, + write_position: WritePosition, + parent_style: str, + erase_bg: bool, + z_index: int | None, + ) -> None: + """ + Write window to screen. This renders the user control, the margins and + copies everything over to the absolute position at the given screen. + """ + # If dont_extend_width/height was given. Then reduce width/height in + # WritePosition if the parent wanted us to paint in a bigger area. + # (This happens if this window is bundled with another window in a + # HSplit/VSplit, but with different size requirements.) + write_position = WritePosition( + xpos=write_position.xpos, + ypos=write_position.ypos, + width=write_position.width, + height=write_position.height, + ) + + if self.dont_extend_width(): + write_position.width = min( + write_position.width, + self.preferred_width(write_position.width).preferred, + ) + + if self.dont_extend_height(): + write_position.height = min( + write_position.height, + self.preferred_height( + write_position.width, write_position.height + ).preferred, + ) + + # Draw + z_index = z_index if self.z_index is None else self.z_index + + draw_func = partial( + self._write_to_screen_at_index, + screen, + mouse_handlers, + write_position, + parent_style, + erase_bg, + ) + + if z_index is None or z_index <= 0: + # When no z_index is given, draw right away. + draw_func() + else: + # Otherwise, postpone. + screen.draw_with_z_index(z_index=z_index, draw_func=draw_func) + + def _write_to_screen_at_index( + self, + screen: Screen, + mouse_handlers: MouseHandlers, + write_position: WritePosition, + parent_style: str, + erase_bg: bool, + ) -> None: + # Don't bother writing invisible windows. + # (We save some time, but also avoid applying last-line styling.) + if write_position.height <= 0 or write_position.width <= 0: + return + + # Calculate margin sizes. + left_margin_widths = [self._get_margin_width(m) for m in self.left_margins] + right_margin_widths = [self._get_margin_width(m) for m in self.right_margins] + total_margin_width = sum(left_margin_widths + right_margin_widths) + + # Render UserControl. + ui_content = self.content.create_content( + write_position.width - total_margin_width, write_position.height + ) + assert isinstance(ui_content, UIContent) + + # Scroll content. + wrap_lines = self.wrap_lines() + self._scroll( + ui_content, write_position.width - total_margin_width, write_position.height + ) + + # Erase background and fill with `char`. + self._fill_bg(screen, write_position, erase_bg) + + # Resolve `align` attribute. + align = self.align() if callable(self.align) else self.align + + # Write body + visible_line_to_row_col, rowcol_to_yx = self._copy_body( + ui_content, + screen, + write_position, + sum(left_margin_widths), + write_position.width - total_margin_width, + self.vertical_scroll, + self.horizontal_scroll, + wrap_lines=wrap_lines, + highlight_lines=True, + vertical_scroll_2=self.vertical_scroll_2, + always_hide_cursor=self.always_hide_cursor(), + has_focus=get_app().layout.current_control == self.content, + align=align, + get_line_prefix=self.get_line_prefix, + ) + + # Remember render info. (Set before generating the margins. They need this.) + x_offset = write_position.xpos + sum(left_margin_widths) + y_offset = write_position.ypos + + render_info = WindowRenderInfo( + window=self, + ui_content=ui_content, + horizontal_scroll=self.horizontal_scroll, + vertical_scroll=self.vertical_scroll, + window_width=write_position.width - total_margin_width, + window_height=write_position.height, + configured_scroll_offsets=self.scroll_offsets, + visible_line_to_row_col=visible_line_to_row_col, + rowcol_to_yx=rowcol_to_yx, + x_offset=x_offset, + y_offset=y_offset, + wrap_lines=wrap_lines, + ) + self.render_info = render_info + + # Set mouse handlers. + def mouse_handler(mouse_event: MouseEvent) -> NotImplementedOrNone: + """ + Wrapper around the mouse_handler of the `UIControl` that turns + screen coordinates into line coordinates. + Returns `NotImplemented` if no UI invalidation should be done. + """ + # Don't handle mouse events outside of the current modal part of + # the UI. + if self not in get_app().layout.walk_through_modal_area(): + return NotImplemented + + # Find row/col position first. + yx_to_rowcol = {v: k for k, v in rowcol_to_yx.items()} + y = mouse_event.position.y + x = mouse_event.position.x + + # If clicked below the content area, look for a position in the + # last line instead. + max_y = write_position.ypos + len(visible_line_to_row_col) - 1 + y = min(max_y, y) + result: NotImplementedOrNone + + while x >= 0: + try: + row, col = yx_to_rowcol[y, x] + except KeyError: + # Try again. (When clicking on the right side of double + # width characters, or on the right side of the input.) + x -= 1 + else: + # Found position, call handler of UIControl. + result = self.content.mouse_handler( + MouseEvent( + position=Point(x=col, y=row), + event_type=mouse_event.event_type, + button=mouse_event.button, + modifiers=mouse_event.modifiers, + ) + ) + break + else: + # nobreak. + # (No x/y coordinate found for the content. This happens in + # case of a DummyControl, that does not have any content. + # Report (0,0) instead.) + result = self.content.mouse_handler( + MouseEvent( + position=Point(x=0, y=0), + event_type=mouse_event.event_type, + button=mouse_event.button, + modifiers=mouse_event.modifiers, + ) + ) + + # If it returns NotImplemented, handle it here. + if result == NotImplemented: + result = self._mouse_handler(mouse_event) + + return result + + mouse_handlers.set_mouse_handler_for_range( + x_min=write_position.xpos + sum(left_margin_widths), + x_max=write_position.xpos + write_position.width - total_margin_width, + y_min=write_position.ypos, + y_max=write_position.ypos + write_position.height, + handler=mouse_handler, + ) + + # Render and copy margins. + move_x = 0 + + def render_margin(m: Margin, width: int) -> UIContent: + "Render margin. Return `Screen`." + # Retrieve margin fragments. + fragments = m.create_margin(render_info, width, write_position.height) + + # Turn it into a UIContent object. + # already rendered those fragments using this size.) + return FormattedTextControl(fragments).create_content( + width + 1, write_position.height + ) + + for m, width in zip(self.left_margins, left_margin_widths): + if width > 0: # (ConditionalMargin returns a zero width. -- Don't render.) + # Create screen for margin. + margin_content = render_margin(m, width) + + # Copy and shift X. + self._copy_margin(margin_content, screen, write_position, move_x, width) + move_x += width + + move_x = write_position.width - sum(right_margin_widths) + + for m, width in zip(self.right_margins, right_margin_widths): + # Create screen for margin. + margin_content = render_margin(m, width) + + # Copy and shift X. + self._copy_margin(margin_content, screen, write_position, move_x, width) + move_x += width + + # Apply 'self.style' + self._apply_style(screen, write_position, parent_style) + + # Tell the screen that this user control has been painted at this + # position. + screen.visible_windows_to_write_positions[self] = write_position + + def _copy_body( + self, + ui_content: UIContent, + new_screen: Screen, + write_position: WritePosition, + move_x: int, + width: int, + vertical_scroll: int = 0, + horizontal_scroll: int = 0, + wrap_lines: bool = False, + highlight_lines: bool = False, + vertical_scroll_2: int = 0, + always_hide_cursor: bool = False, + has_focus: bool = False, + align: WindowAlign = WindowAlign.LEFT, + get_line_prefix: Callable[[int, int], AnyFormattedText] | None = None, + ) -> tuple[dict[int, tuple[int, int]], dict[tuple[int, int], tuple[int, int]]]: + """ + Copy the UIContent into the output screen. + Return (visible_line_to_row_col, rowcol_to_yx) tuple. + + :param get_line_prefix: None or a callable that takes a line number + (int) and a wrap_count (int) and returns formatted text. + """ + xpos = write_position.xpos + move_x + ypos = write_position.ypos + line_count = ui_content.line_count + new_buffer = new_screen.data_buffer + empty_char = _CHAR_CACHE["", ""] + + # Map visible line number to (row, col) of input. + # 'col' will always be zero if line wrapping is off. + visible_line_to_row_col: dict[int, tuple[int, int]] = {} + + # Maps (row, col) from the input to (y, x) screen coordinates. + rowcol_to_yx: dict[tuple[int, int], tuple[int, int]] = {} + + def copy_line( + line: StyleAndTextTuples, + lineno: int, + x: int, + y: int, + is_input: bool = False, + ) -> tuple[int, int]: + """ + Copy over a single line to the output screen. This can wrap over + multiple lines in the output. It will call the prefix (prompt) + function before every line. + """ + if is_input: + current_rowcol_to_yx = rowcol_to_yx + else: + current_rowcol_to_yx = {} # Throwaway dictionary. + + # Draw line prefix. + if is_input and get_line_prefix: + prompt = to_formatted_text(get_line_prefix(lineno, 0)) + x, y = copy_line(prompt, lineno, x, y, is_input=False) + + # Scroll horizontally. + skipped = 0 # Characters skipped because of horizontal scrolling. + if horizontal_scroll and is_input: + h_scroll = horizontal_scroll + line = explode_text_fragments(line) + while h_scroll > 0 and line: + h_scroll -= get_cwidth(line[0][1]) + skipped += 1 + del line[:1] # Remove first character. + + x -= h_scroll # When scrolling over double width character, + # this can end up being negative. + + # Align this line. (Note that this doesn't work well when we use + # get_line_prefix and that function returns variable width prefixes.) + if align == WindowAlign.CENTER: + line_width = fragment_list_width(line) + if line_width < width: + x += (width - line_width) // 2 + elif align == WindowAlign.RIGHT: + line_width = fragment_list_width(line) + if line_width < width: + x += width - line_width + + col = 0 + wrap_count = 0 + for style, text, *_ in line: + new_buffer_row = new_buffer[y + ypos] + + # Remember raw VT escape sequences. (E.g. FinalTerm's + # escape sequences.) + if "[ZeroWidthEscape]" in style: + new_screen.zero_width_escapes[y + ypos][x + xpos] += text + continue + + for c in text: + char = _CHAR_CACHE[c, style] + char_width = char.width + + # Wrap when the line width is exceeded. + if wrap_lines and x + char_width > width: + visible_line_to_row_col[y + 1] = ( + lineno, + visible_line_to_row_col[y][1] + x, + ) + y += 1 + wrap_count += 1 + x = 0 + + # Insert line prefix (continuation prompt). + if is_input and get_line_prefix: + prompt = to_formatted_text( + get_line_prefix(lineno, wrap_count) + ) + x, y = copy_line(prompt, lineno, x, y, is_input=False) + + new_buffer_row = new_buffer[y + ypos] + + if y >= write_position.height: + return x, y # Break out of all for loops. + + # Set character in screen and shift 'x'. + if x >= 0 and y >= 0 and x < width: + new_buffer_row[x + xpos] = char + + # When we print a multi width character, make sure + # to erase the neighbors positions in the screen. + # (The empty string if different from everything, + # so next redraw this cell will repaint anyway.) + if char_width > 1: + for i in range(1, char_width): + new_buffer_row[x + xpos + i] = empty_char + + # If this is a zero width characters, then it's + # probably part of a decomposed unicode character. + # See: https://en.wikipedia.org/wiki/Unicode_equivalence + # Merge it in the previous cell. + elif char_width == 0: + # Handle all character widths. If the previous + # character is a multiwidth character, then + # merge it two positions back. + for pw in [2, 1]: # Previous character width. + if ( + x - pw >= 0 + and new_buffer_row[x + xpos - pw].width == pw + ): + prev_char = new_buffer_row[x + xpos - pw] + char2 = _CHAR_CACHE[ + prev_char.char + c, prev_char.style + ] + new_buffer_row[x + xpos - pw] = char2 + + # Keep track of write position for each character. + current_rowcol_to_yx[lineno, col + skipped] = ( + y + ypos, + x + xpos, + ) + + col += 1 + x += char_width + return x, y + + # Copy content. + def copy() -> int: + y = -vertical_scroll_2 + lineno = vertical_scroll + + while y < write_position.height and lineno < line_count: + # Take the next line and copy it in the real screen. + line = ui_content.get_line(lineno) + + visible_line_to_row_col[y] = (lineno, horizontal_scroll) + + # Copy margin and actual line. + x = 0 + x, y = copy_line(line, lineno, x, y, is_input=True) + + lineno += 1 + y += 1 + return y + + copy() + + def cursor_pos_to_screen_pos(row: int, col: int) -> Point: + "Translate row/col from UIContent to real Screen coordinates." + try: + y, x = rowcol_to_yx[row, col] + except KeyError: + # Normally this should never happen. (It is a bug, if it happens.) + # But to be sure, return (0, 0) + return Point(x=0, y=0) + + # raise ValueError( + # 'Invalid position. row=%r col=%r, vertical_scroll=%r, ' + # 'horizontal_scroll=%r, height=%r' % + # (row, col, vertical_scroll, horizontal_scroll, write_position.height)) + else: + return Point(x=x, y=y) + + # Set cursor and menu positions. + if ui_content.cursor_position: + screen_cursor_position = cursor_pos_to_screen_pos( + ui_content.cursor_position.y, ui_content.cursor_position.x + ) + + if has_focus: + new_screen.set_cursor_position(self, screen_cursor_position) + + if always_hide_cursor: + new_screen.show_cursor = False + else: + new_screen.show_cursor = ui_content.show_cursor + + self._highlight_digraph(new_screen) + + if highlight_lines: + self._highlight_cursorlines( + new_screen, + screen_cursor_position, + xpos, + ypos, + width, + write_position.height, + ) + + # Draw input characters from the input processor queue. + if has_focus and ui_content.cursor_position: + self._show_key_processor_key_buffer(new_screen) + + # Set menu position. + if ui_content.menu_position: + new_screen.set_menu_position( + self, + cursor_pos_to_screen_pos( + ui_content.menu_position.y, ui_content.menu_position.x + ), + ) + + # Update output screen height. + new_screen.height = max(new_screen.height, ypos + write_position.height) + + return visible_line_to_row_col, rowcol_to_yx + + def _fill_bg( + self, screen: Screen, write_position: WritePosition, erase_bg: bool + ) -> None: + """ + Erase/fill the background. + (Useful for floats and when a `char` has been given.) + """ + char: str | None + if callable(self.char): + char = self.char() + else: + char = self.char + + if erase_bg or char: + wp = write_position + char_obj = _CHAR_CACHE[char or " ", ""] + + for y in range(wp.ypos, wp.ypos + wp.height): + row = screen.data_buffer[y] + for x in range(wp.xpos, wp.xpos + wp.width): + row[x] = char_obj + + def _apply_style( + self, new_screen: Screen, write_position: WritePosition, parent_style: str + ) -> None: + # Apply `self.style`. + style = parent_style + " " + to_str(self.style) + + new_screen.fill_area(write_position, style=style, after=False) + + # Apply the 'last-line' class to the last line of each Window. This can + # be used to apply an 'underline' to the user control. + wp = WritePosition( + write_position.xpos, + write_position.ypos + write_position.height - 1, + write_position.width, + 1, + ) + new_screen.fill_area(wp, "class:last-line", after=True) + + def _highlight_digraph(self, new_screen: Screen) -> None: + """ + When we are in Vi digraph mode, put a question mark underneath the + cursor. + """ + digraph_char = self._get_digraph_char() + if digraph_char: + cpos = new_screen.get_cursor_position(self) + new_screen.data_buffer[cpos.y][cpos.x] = _CHAR_CACHE[ + digraph_char, "class:digraph" + ] + + def _show_key_processor_key_buffer(self, new_screen: Screen) -> None: + """ + When the user is typing a key binding that consists of several keys, + display the last pressed key if the user is in insert mode and the key + is meaningful to be displayed. + E.g. Some people want to bind 'jj' to escape in Vi insert mode. But the + first 'j' needs to be displayed in order to get some feedback. + """ + app = get_app() + key_buffer = app.key_processor.key_buffer + + if key_buffer and _in_insert_mode() and not app.is_done: + # The textual data for the given key. (Can be a VT100 escape + # sequence.) + data = key_buffer[-1].data + + # Display only if this is a 1 cell width character. + if get_cwidth(data) == 1: + cpos = new_screen.get_cursor_position(self) + new_screen.data_buffer[cpos.y][cpos.x] = _CHAR_CACHE[ + data, "class:partial-key-binding" + ] + + def _highlight_cursorlines( + self, new_screen: Screen, cpos: Point, x: int, y: int, width: int, height: int + ) -> None: + """ + Highlight cursor row/column. + """ + cursor_line_style = " class:cursor-line " + cursor_column_style = " class:cursor-column " + + data_buffer = new_screen.data_buffer + + # Highlight cursor line. + if self.cursorline(): + row = data_buffer[cpos.y] + for x in range(x, x + width): + original_char = row[x] + row[x] = _CHAR_CACHE[ + original_char.char, original_char.style + cursor_line_style + ] + + # Highlight cursor column. + if self.cursorcolumn(): + for y2 in range(y, y + height): + row = data_buffer[y2] + original_char = row[cpos.x] + row[cpos.x] = _CHAR_CACHE[ + original_char.char, original_char.style + cursor_column_style + ] + + # Highlight color columns + colorcolumns = self.colorcolumns + if callable(colorcolumns): + colorcolumns = colorcolumns() + + for cc in colorcolumns: + assert isinstance(cc, ColorColumn) + column = cc.position + + if column < x + width: # Only draw when visible. + color_column_style = " " + cc.style + + for y2 in range(y, y + height): + row = data_buffer[y2] + original_char = row[column + x] + row[column + x] = _CHAR_CACHE[ + original_char.char, original_char.style + color_column_style + ] + + def _copy_margin( + self, + margin_content: UIContent, + new_screen: Screen, + write_position: WritePosition, + move_x: int, + width: int, + ) -> None: + """ + Copy characters from the margin screen to the real screen. + """ + xpos = write_position.xpos + move_x + ypos = write_position.ypos + + margin_write_position = WritePosition(xpos, ypos, width, write_position.height) + self._copy_body(margin_content, new_screen, margin_write_position, 0, width) + + def _scroll(self, ui_content: UIContent, width: int, height: int) -> None: + """ + Scroll body. Ensure that the cursor is visible. + """ + if self.wrap_lines(): + func = self._scroll_when_linewrapping + else: + func = self._scroll_without_linewrapping + + func(ui_content, width, height) + + def _scroll_when_linewrapping( + self, ui_content: UIContent, width: int, height: int + ) -> None: + """ + Scroll to make sure the cursor position is visible and that we maintain + the requested scroll offset. + + Set `self.horizontal_scroll/vertical_scroll`. + """ + scroll_offsets_bottom = self.scroll_offsets.bottom + scroll_offsets_top = self.scroll_offsets.top + + # We don't have horizontal scrolling. + self.horizontal_scroll = 0 + + def get_line_height(lineno: int) -> int: + return ui_content.get_height_for_line(lineno, width, self.get_line_prefix) + + # When there is no space, reset `vertical_scroll_2` to zero and abort. + # This can happen if the margin is bigger than the window width. + # Otherwise the text height will become "infinite" (a big number) and + # the copy_line will spend a huge amount of iterations trying to render + # nothing. + if width <= 0: + self.vertical_scroll = ui_content.cursor_position.y + self.vertical_scroll_2 = 0 + return + + # If the current line consumes more than the whole window height, + # then we have to scroll vertically inside this line. (We don't take + # the scroll offsets into account for this.) + # Also, ignore the scroll offsets in this case. Just set the vertical + # scroll to this line. + line_height = get_line_height(ui_content.cursor_position.y) + if line_height > height - scroll_offsets_top: + # Calculate the height of the text before the cursor (including + # line prefixes). + text_before_height = ui_content.get_height_for_line( + ui_content.cursor_position.y, + width, + self.get_line_prefix, + slice_stop=ui_content.cursor_position.x, + ) + + # Adjust scroll offset. + self.vertical_scroll = ui_content.cursor_position.y + self.vertical_scroll_2 = min( + text_before_height - 1, # Keep the cursor visible. + line_height + - height, # Avoid blank lines at the bottom when scrolling up again. + self.vertical_scroll_2, + ) + self.vertical_scroll_2 = max( + 0, text_before_height - height, self.vertical_scroll_2 + ) + return + else: + self.vertical_scroll_2 = 0 + + # Current line doesn't consume the whole height. Take scroll offsets into account. + def get_min_vertical_scroll() -> int: + # Make sure that the cursor line is not below the bottom. + # (Calculate how many lines can be shown between the cursor and the .) + used_height = 0 + prev_lineno = ui_content.cursor_position.y + + for lineno in range(ui_content.cursor_position.y, -1, -1): + used_height += get_line_height(lineno) + + if used_height > height - scroll_offsets_bottom: + return prev_lineno + else: + prev_lineno = lineno + return 0 + + def get_max_vertical_scroll() -> int: + # Make sure that the cursor line is not above the top. + prev_lineno = ui_content.cursor_position.y + used_height = 0 + + for lineno in range(ui_content.cursor_position.y - 1, -1, -1): + used_height += get_line_height(lineno) + + if used_height > scroll_offsets_top: + return prev_lineno + else: + prev_lineno = lineno + return prev_lineno + + def get_topmost_visible() -> int: + """ + Calculate the upper most line that can be visible, while the bottom + is still visible. We should not allow scroll more than this if + `allow_scroll_beyond_bottom` is false. + """ + prev_lineno = ui_content.line_count - 1 + used_height = 0 + for lineno in range(ui_content.line_count - 1, -1, -1): + used_height += get_line_height(lineno) + if used_height > height: + return prev_lineno + else: + prev_lineno = lineno + return prev_lineno + + # Scroll vertically. (Make sure that the whole line which contains the + # cursor is visible. + topmost_visible = get_topmost_visible() + + # Note: the `min(topmost_visible, ...)` is to make sure that we + # don't require scrolling up because of the bottom scroll offset, + # when we are at the end of the document. + self.vertical_scroll = max( + self.vertical_scroll, min(topmost_visible, get_min_vertical_scroll()) + ) + self.vertical_scroll = min(self.vertical_scroll, get_max_vertical_scroll()) + + # Disallow scrolling beyond bottom? + if not self.allow_scroll_beyond_bottom(): + self.vertical_scroll = min(self.vertical_scroll, topmost_visible) + + def _scroll_without_linewrapping( + self, ui_content: UIContent, width: int, height: int + ) -> None: + """ + Scroll to make sure the cursor position is visible and that we maintain + the requested scroll offset. + + Set `self.horizontal_scroll/vertical_scroll`. + """ + cursor_position = ui_content.cursor_position or Point(x=0, y=0) + + # Without line wrapping, we will never have to scroll vertically inside + # a single line. + self.vertical_scroll_2 = 0 + + if ui_content.line_count == 0: + self.vertical_scroll = 0 + self.horizontal_scroll = 0 + return + else: + current_line_text = fragment_list_to_text( + ui_content.get_line(cursor_position.y) + ) + + def do_scroll( + current_scroll: int, + scroll_offset_start: int, + scroll_offset_end: int, + cursor_pos: int, + window_size: int, + content_size: int, + ) -> int: + "Scrolling algorithm. Used for both horizontal and vertical scrolling." + # Calculate the scroll offset to apply. + # This can obviously never be more than have the screen size. Also, when the + # cursor appears at the top or bottom, we don't apply the offset. + scroll_offset_start = int( + min(scroll_offset_start, window_size / 2, cursor_pos) + ) + scroll_offset_end = int( + min(scroll_offset_end, window_size / 2, content_size - 1 - cursor_pos) + ) + + # Prevent negative scroll offsets. + if current_scroll < 0: + current_scroll = 0 + + # Scroll back if we scrolled to much and there's still space to show more of the document. + if ( + not self.allow_scroll_beyond_bottom() + and current_scroll > content_size - window_size + ): + current_scroll = max(0, content_size - window_size) + + # Scroll up if cursor is before visible part. + if current_scroll > cursor_pos - scroll_offset_start: + current_scroll = max(0, cursor_pos - scroll_offset_start) + + # Scroll down if cursor is after visible part. + if current_scroll < (cursor_pos + 1) - window_size + scroll_offset_end: + current_scroll = (cursor_pos + 1) - window_size + scroll_offset_end + + return current_scroll + + # When a preferred scroll is given, take that first into account. + if self.get_vertical_scroll: + self.vertical_scroll = self.get_vertical_scroll(self) + assert isinstance(self.vertical_scroll, int) + if self.get_horizontal_scroll: + self.horizontal_scroll = self.get_horizontal_scroll(self) + assert isinstance(self.horizontal_scroll, int) + + # Update horizontal/vertical scroll to make sure that the cursor + # remains visible. + offsets = self.scroll_offsets + + self.vertical_scroll = do_scroll( + current_scroll=self.vertical_scroll, + scroll_offset_start=offsets.top, + scroll_offset_end=offsets.bottom, + cursor_pos=ui_content.cursor_position.y, + window_size=height, + content_size=ui_content.line_count, + ) + + if self.get_line_prefix: + current_line_prefix_width = fragment_list_width( + to_formatted_text(self.get_line_prefix(ui_content.cursor_position.y, 0)) + ) + else: + current_line_prefix_width = 0 + + self.horizontal_scroll = do_scroll( + current_scroll=self.horizontal_scroll, + scroll_offset_start=offsets.left, + scroll_offset_end=offsets.right, + cursor_pos=get_cwidth(current_line_text[: ui_content.cursor_position.x]), + window_size=width - current_line_prefix_width, + # We can only analyze the current line. Calculating the width off + # all the lines is too expensive. + content_size=max( + get_cwidth(current_line_text), self.horizontal_scroll + width + ), + ) + + def _mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone: + """ + Mouse handler. Called when the UI control doesn't handle this + particular event. + + Return `NotImplemented` if nothing was done as a consequence of this + key binding (no UI invalidate required in that case). + """ + if mouse_event.event_type == MouseEventType.SCROLL_DOWN: + self._scroll_down() + return None + elif mouse_event.event_type == MouseEventType.SCROLL_UP: + self._scroll_up() + return None + + return NotImplemented + + def _scroll_down(self) -> None: + "Scroll window down." + info = self.render_info + + if info is None: + return + + if self.vertical_scroll < info.content_height - info.window_height: + if info.cursor_position.y <= info.configured_scroll_offsets.top: + self.content.move_cursor_down() + + self.vertical_scroll += 1 + + def _scroll_up(self) -> None: + "Scroll window up." + info = self.render_info + + if info is None: + return + + if info.vertical_scroll > 0: + # TODO: not entirely correct yet in case of line wrapping and long lines. + if ( + info.cursor_position.y + >= info.window_height - 1 - info.configured_scroll_offsets.bottom + ): + self.content.move_cursor_up() + + self.vertical_scroll -= 1 + + def get_key_bindings(self) -> KeyBindingsBase | None: + return self.content.get_key_bindings() + + def get_children(self) -> list[Container]: + return [] + + +class ConditionalContainer(Container): + """ + Wrapper around any other container that can change the visibility. The + received `filter` determines whether the given container should be + displayed or not. + + :param content: :class:`.Container` instance. + :param filter: :class:`.Filter` instance. + """ + + def __init__(self, content: AnyContainer, filter: FilterOrBool) -> None: + self.content = to_container(content) + self.filter = to_filter(filter) + + def __repr__(self) -> str: + return f"ConditionalContainer({self.content!r}, filter={self.filter!r})" + + def reset(self) -> None: + self.content.reset() + + def preferred_width(self, max_available_width: int) -> Dimension: + if self.filter(): + return self.content.preferred_width(max_available_width) + else: + return Dimension.zero() + + def preferred_height(self, width: int, max_available_height: int) -> Dimension: + if self.filter(): + return self.content.preferred_height(width, max_available_height) + else: + return Dimension.zero() + + def write_to_screen( + self, + screen: Screen, + mouse_handlers: MouseHandlers, + write_position: WritePosition, + parent_style: str, + erase_bg: bool, + z_index: int | None, + ) -> None: + if self.filter(): + return self.content.write_to_screen( + screen, mouse_handlers, write_position, parent_style, erase_bg, z_index + ) + + def get_children(self) -> list[Container]: + return [self.content] + + +class DynamicContainer(Container): + """ + Container class that dynamically returns any Container. + + :param get_container: Callable that returns a :class:`.Container` instance + or any widget with a ``__pt_container__`` method. + """ + + def __init__(self, get_container: Callable[[], AnyContainer]) -> None: + self.get_container = get_container + + def _get_container(self) -> Container: + """ + Return the current container object. + + We call `to_container`, because `get_container` can also return a + widget with a ``__pt_container__`` method. + """ + obj = self.get_container() + return to_container(obj) + + def reset(self) -> None: + self._get_container().reset() + + def preferred_width(self, max_available_width: int) -> Dimension: + return self._get_container().preferred_width(max_available_width) + + def preferred_height(self, width: int, max_available_height: int) -> Dimension: + return self._get_container().preferred_height(width, max_available_height) + + def write_to_screen( + self, + screen: Screen, + mouse_handlers: MouseHandlers, + write_position: WritePosition, + parent_style: str, + erase_bg: bool, + z_index: int | None, + ) -> None: + self._get_container().write_to_screen( + screen, mouse_handlers, write_position, parent_style, erase_bg, z_index + ) + + def is_modal(self) -> bool: + return False + + def get_key_bindings(self) -> KeyBindingsBase | None: + # Key bindings will be collected when `layout.walk()` finds the child + # container. + return None + + def get_children(self) -> list[Container]: + # Here we have to return the current active container itself, not its + # children. Otherwise, we run into issues where `layout.walk()` will + # never see an object of type `Window` if this contains a window. We + # can't/shouldn't proxy the "isinstance" check. + return [self._get_container()] + + +def to_container(container: AnyContainer) -> Container: + """ + Make sure that the given object is a :class:`.Container`. + """ + if isinstance(container, Container): + return container + elif hasattr(container, "__pt_container__"): + return to_container(container.__pt_container__()) + else: + raise ValueError(f"Not a container object: {container!r}") + + +def to_window(container: AnyContainer) -> Window: + """ + Make sure that the given argument is a :class:`.Window`. + """ + if isinstance(container, Window): + return container + elif hasattr(container, "__pt_container__"): + return to_window(cast("MagicContainer", container).__pt_container__()) + else: + raise ValueError(f"Not a Window object: {container!r}.") + + +def is_container(value: object) -> TypeGuard[AnyContainer]: + """ + Checks whether the given value is a container object + (for use in assert statements). + """ + if isinstance(value, Container): + return True + if hasattr(value, "__pt_container__"): + return is_container(cast("MagicContainer", value).__pt_container__()) + return False diff --git a/src/prompt_toolkit/layout/controls.py b/src/prompt_toolkit/layout/controls.py new file mode 100644 index 0000000..c30c0ef --- /dev/null +++ b/src/prompt_toolkit/layout/controls.py @@ -0,0 +1,944 @@ +""" +User interface Controls for the layout. +""" +from __future__ import annotations + +import time +from abc import ABCMeta, abstractmethod +from typing import TYPE_CHECKING, Callable, Hashable, Iterable, NamedTuple + +from prompt_toolkit.application.current import get_app +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.cache import SimpleCache +from prompt_toolkit.data_structures import Point +from prompt_toolkit.document import Document +from prompt_toolkit.filters import FilterOrBool, to_filter +from prompt_toolkit.formatted_text import ( + AnyFormattedText, + StyleAndTextTuples, + to_formatted_text, +) +from prompt_toolkit.formatted_text.utils import ( + fragment_list_to_text, + fragment_list_width, + split_lines, +) +from prompt_toolkit.lexers import Lexer, SimpleLexer +from prompt_toolkit.mouse_events import MouseButton, MouseEvent, MouseEventType +from prompt_toolkit.search import SearchState +from prompt_toolkit.selection import SelectionType +from prompt_toolkit.utils import get_cwidth + +from .processors import ( + DisplayMultipleCursors, + HighlightIncrementalSearchProcessor, + HighlightSearchProcessor, + HighlightSelectionProcessor, + Processor, + TransformationInput, + merge_processors, +) + +if TYPE_CHECKING: + from prompt_toolkit.key_binding.key_bindings import ( + KeyBindingsBase, + NotImplementedOrNone, + ) + from prompt_toolkit.utils import Event + + +__all__ = [ + "BufferControl", + "SearchBufferControl", + "DummyControl", + "FormattedTextControl", + "UIControl", + "UIContent", +] + +GetLinePrefixCallable = Callable[[int, int], AnyFormattedText] + + +class UIControl(metaclass=ABCMeta): + """ + Base class for all user interface controls. + """ + + def reset(self) -> None: + # Default reset. (Doesn't have to be implemented.) + pass + + def preferred_width(self, max_available_width: int) -> int | None: + return None + + def preferred_height( + self, + width: int, + max_available_height: int, + wrap_lines: bool, + get_line_prefix: GetLinePrefixCallable | None, + ) -> int | None: + return None + + def is_focusable(self) -> bool: + """ + Tell whether this user control is focusable. + """ + return False + + @abstractmethod + def create_content(self, width: int, height: int) -> UIContent: + """ + Generate the content for this user control. + + Returns a :class:`.UIContent` instance. + """ + + def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone: + """ + Handle mouse events. + + When `NotImplemented` is returned, it means that the given event is not + handled by the `UIControl` itself. The `Window` or key bindings can + decide to handle this event as scrolling or changing focus. + + :param mouse_event: `MouseEvent` instance. + """ + return NotImplemented + + def move_cursor_down(self) -> None: + """ + Request to move the cursor down. + This happens when scrolling down and the cursor is completely at the + top. + """ + + def move_cursor_up(self) -> None: + """ + Request to move the cursor up. + """ + + def get_key_bindings(self) -> KeyBindingsBase | None: + """ + The key bindings that are specific for this user control. + + Return a :class:`.KeyBindings` object if some key bindings are + specified, or `None` otherwise. + """ + + def get_invalidate_events(self) -> Iterable[Event[object]]: + """ + Return a list of `Event` objects. This can be a generator. + (The application collects all these events, in order to bind redraw + handlers to these events.) + """ + return [] + + +class UIContent: + """ + Content generated by a user control. This content consists of a list of + lines. + + :param get_line: Callable that takes a line number and returns the current + line. This is a list of (style_str, text) tuples. + :param line_count: The number of lines. + :param cursor_position: a :class:`.Point` for the cursor position. + :param menu_position: a :class:`.Point` for the menu position. + :param show_cursor: Make the cursor visible. + """ + + def __init__( + self, + get_line: Callable[[int], StyleAndTextTuples] = (lambda i: []), + line_count: int = 0, + cursor_position: Point | None = None, + menu_position: Point | None = None, + show_cursor: bool = True, + ): + self.get_line = get_line + self.line_count = line_count + self.cursor_position = cursor_position or Point(x=0, y=0) + self.menu_position = menu_position + self.show_cursor = show_cursor + + # Cache for line heights. Maps cache key -> height + self._line_heights_cache: dict[Hashable, int] = {} + + def __getitem__(self, lineno: int) -> StyleAndTextTuples: + "Make it iterable (iterate line by line)." + if lineno < self.line_count: + return self.get_line(lineno) + else: + raise IndexError + + def get_height_for_line( + self, + lineno: int, + width: int, + get_line_prefix: GetLinePrefixCallable | None, + slice_stop: int | None = None, + ) -> int: + """ + Return the height that a given line would need if it is rendered in a + space with the given width (using line wrapping). + + :param get_line_prefix: None or a `Window.get_line_prefix` callable + that returns the prefix to be inserted before this line. + :param slice_stop: Wrap only "line[:slice_stop]" and return that + partial result. This is needed for scrolling the window correctly + when line wrapping. + :returns: The computed height. + """ + # Instead of using `get_line_prefix` as key, we use render_counter + # instead. This is more reliable, because this function could still be + # the same, while the content would change over time. + key = get_app().render_counter, lineno, width, slice_stop + + try: + return self._line_heights_cache[key] + except KeyError: + if width == 0: + height = 10**8 + else: + # Calculate line width first. + line = fragment_list_to_text(self.get_line(lineno))[:slice_stop] + text_width = get_cwidth(line) + + if get_line_prefix: + # Add prefix width. + text_width += fragment_list_width( + to_formatted_text(get_line_prefix(lineno, 0)) + ) + + # Slower path: compute path when there's a line prefix. + height = 1 + + # Keep wrapping as long as the line doesn't fit. + # Keep adding new prefixes for every wrapped line. + while text_width > width: + height += 1 + text_width -= width + + fragments2 = to_formatted_text( + get_line_prefix(lineno, height - 1) + ) + prefix_width = get_cwidth(fragment_list_to_text(fragments2)) + + if prefix_width >= width: # Prefix doesn't fit. + height = 10**8 + break + + text_width += prefix_width + else: + # Fast path: compute height when there's no line prefix. + try: + quotient, remainder = divmod(text_width, width) + except ZeroDivisionError: + height = 10**8 + else: + if remainder: + quotient += 1 # Like math.ceil. + height = max(1, quotient) + + # Cache and return + self._line_heights_cache[key] = height + return height + + +class FormattedTextControl(UIControl): + """ + Control that displays formatted text. This can be either plain text, an + :class:`~prompt_toolkit.formatted_text.HTML` object an + :class:`~prompt_toolkit.formatted_text.ANSI` object, a list of ``(style_str, + text)`` tuples or a callable that takes no argument and returns one of + those, depending on how you prefer to do the formatting. See + ``prompt_toolkit.layout.formatted_text`` for more information. + + (It's mostly optimized for rather small widgets, like toolbars, menus, etc...) + + When this UI control has the focus, the cursor will be shown in the upper + left corner of this control by default. There are two ways for specifying + the cursor position: + + - Pass a `get_cursor_position` function which returns a `Point` instance + with the current cursor position. + + - If the (formatted) text is passed as a list of ``(style, text)`` tuples + and there is one that looks like ``('[SetCursorPosition]', '')``, then + this will specify the cursor position. + + Mouse support: + + The list of fragments can also contain tuples of three items, looking like: + (style_str, text, handler). When mouse support is enabled and the user + clicks on this fragment, then the given handler is called. That handler + should accept two inputs: (Application, MouseEvent) and it should + either handle the event or return `NotImplemented` in case we want the + containing Window to handle this event. + + :param focusable: `bool` or :class:`.Filter`: Tell whether this control is + focusable. + + :param text: Text or formatted text to be displayed. + :param style: Style string applied to the content. (If you want to style + the whole :class:`~prompt_toolkit.layout.Window`, pass the style to the + :class:`~prompt_toolkit.layout.Window` instead.) + :param key_bindings: a :class:`.KeyBindings` object. + :param get_cursor_position: A callable that returns the cursor position as + a `Point` instance. + """ + + def __init__( + self, + text: AnyFormattedText = "", + style: str = "", + focusable: FilterOrBool = False, + key_bindings: KeyBindingsBase | None = None, + show_cursor: bool = True, + modal: bool = False, + get_cursor_position: Callable[[], Point | None] | None = None, + ) -> None: + self.text = text # No type check on 'text'. This is done dynamically. + self.style = style + self.focusable = to_filter(focusable) + + # Key bindings. + self.key_bindings = key_bindings + self.show_cursor = show_cursor + self.modal = modal + self.get_cursor_position = get_cursor_position + + #: Cache for the content. + self._content_cache: SimpleCache[Hashable, UIContent] = SimpleCache(maxsize=18) + self._fragment_cache: SimpleCache[int, StyleAndTextTuples] = SimpleCache( + maxsize=1 + ) + # Only cache one fragment list. We don't need the previous item. + + # Render info for the mouse support. + self._fragments: StyleAndTextTuples | None = None + + def reset(self) -> None: + self._fragments = None + + def is_focusable(self) -> bool: + return self.focusable() + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.text!r})" + + def _get_formatted_text_cached(self) -> StyleAndTextTuples: + """ + Get fragments, but only retrieve fragments once during one render run. + (This function is called several times during one rendering, because + we also need those for calculating the dimensions.) + """ + return self._fragment_cache.get( + get_app().render_counter, lambda: to_formatted_text(self.text, self.style) + ) + + def preferred_width(self, max_available_width: int) -> int: + """ + Return the preferred width for this control. + That is the width of the longest line. + """ + text = fragment_list_to_text(self._get_formatted_text_cached()) + line_lengths = [get_cwidth(l) for l in text.split("\n")] + return max(line_lengths) + + def preferred_height( + self, + width: int, + max_available_height: int, + wrap_lines: bool, + get_line_prefix: GetLinePrefixCallable | None, + ) -> int | None: + """ + Return the preferred height for this control. + """ + content = self.create_content(width, None) + if wrap_lines: + height = 0 + for i in range(content.line_count): + height += content.get_height_for_line(i, width, get_line_prefix) + if height >= max_available_height: + return max_available_height + return height + else: + return content.line_count + + def create_content(self, width: int, height: int | None) -> UIContent: + # Get fragments + fragments_with_mouse_handlers = self._get_formatted_text_cached() + fragment_lines_with_mouse_handlers = list( + split_lines(fragments_with_mouse_handlers) + ) + + # Strip mouse handlers from fragments. + fragment_lines: list[StyleAndTextTuples] = [ + [(item[0], item[1]) for item in line] + for line in fragment_lines_with_mouse_handlers + ] + + # Keep track of the fragments with mouse handler, for later use in + # `mouse_handler`. + self._fragments = fragments_with_mouse_handlers + + # If there is a `[SetCursorPosition]` in the fragment list, set the + # cursor position here. + def get_cursor_position( + fragment: str = "[SetCursorPosition]", + ) -> Point | None: + for y, line in enumerate(fragment_lines): + x = 0 + for style_str, text, *_ in line: + if fragment in style_str: + return Point(x=x, y=y) + x += len(text) + return None + + # If there is a `[SetMenuPosition]`, set the menu over here. + def get_menu_position() -> Point | None: + return get_cursor_position("[SetMenuPosition]") + + cursor_position = (self.get_cursor_position or get_cursor_position)() + + # Create content, or take it from the cache. + key = (tuple(fragments_with_mouse_handlers), width, cursor_position) + + def get_content() -> UIContent: + return UIContent( + get_line=lambda i: fragment_lines[i], + line_count=len(fragment_lines), + show_cursor=self.show_cursor, + cursor_position=cursor_position, + menu_position=get_menu_position(), + ) + + return self._content_cache.get(key, get_content) + + def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone: + """ + Handle mouse events. + + (When the fragment list contained mouse handlers and the user clicked on + on any of these, the matching handler is called. This handler can still + return `NotImplemented` in case we want the + :class:`~prompt_toolkit.layout.Window` to handle this particular + event.) + """ + if self._fragments: + # Read the generator. + fragments_for_line = list(split_lines(self._fragments)) + + try: + fragments = fragments_for_line[mouse_event.position.y] + except IndexError: + return NotImplemented + else: + # Find position in the fragment list. + xpos = mouse_event.position.x + + # Find mouse handler for this character. + count = 0 + for item in fragments: + count += len(item[1]) + if count > xpos: + if len(item) >= 3: + # Handler found. Call it. + # (Handler can return NotImplemented, so return + # that result.) + handler = item[2] + return handler(mouse_event) + else: + break + + # Otherwise, don't handle here. + return NotImplemented + + def is_modal(self) -> bool: + return self.modal + + def get_key_bindings(self) -> KeyBindingsBase | None: + return self.key_bindings + + +class DummyControl(UIControl): + """ + A dummy control object that doesn't paint any content. + + Useful for filling a :class:`~prompt_toolkit.layout.Window`. (The + `fragment` and `char` attributes of the `Window` class can be used to + define the filling.) + """ + + def create_content(self, width: int, height: int) -> UIContent: + def get_line(i: int) -> StyleAndTextTuples: + return [] + + return UIContent(get_line=get_line, line_count=100**100) # Something very big. + + def is_focusable(self) -> bool: + return False + + +class _ProcessedLine(NamedTuple): + fragments: StyleAndTextTuples + source_to_display: Callable[[int], int] + display_to_source: Callable[[int], int] + + +class BufferControl(UIControl): + """ + Control for visualizing the content of a :class:`.Buffer`. + + :param buffer: The :class:`.Buffer` object to be displayed. + :param input_processors: A list of + :class:`~prompt_toolkit.layout.processors.Processor` objects. + :param include_default_input_processors: When True, include the default + processors for highlighting of selection, search and displaying of + multiple cursors. + :param lexer: :class:`.Lexer` instance for syntax highlighting. + :param preview_search: `bool` or :class:`.Filter`: Show search while + typing. When this is `True`, probably you want to add a + ``HighlightIncrementalSearchProcessor`` as well. Otherwise only the + cursor position will move, but the text won't be highlighted. + :param focusable: `bool` or :class:`.Filter`: Tell whether this control is focusable. + :param focus_on_click: Focus this buffer when it's click, but not yet focused. + :param key_bindings: a :class:`.KeyBindings` object. + """ + + def __init__( + self, + buffer: Buffer | None = None, + input_processors: list[Processor] | None = None, + include_default_input_processors: bool = True, + lexer: Lexer | None = None, + preview_search: FilterOrBool = False, + focusable: FilterOrBool = True, + search_buffer_control: ( + None | SearchBufferControl | Callable[[], SearchBufferControl] + ) = None, + menu_position: Callable[[], int | None] | None = None, + focus_on_click: FilterOrBool = False, + key_bindings: KeyBindingsBase | None = None, + ): + self.input_processors = input_processors + self.include_default_input_processors = include_default_input_processors + + self.default_input_processors = [ + HighlightSearchProcessor(), + HighlightIncrementalSearchProcessor(), + HighlightSelectionProcessor(), + DisplayMultipleCursors(), + ] + + self.preview_search = to_filter(preview_search) + self.focusable = to_filter(focusable) + self.focus_on_click = to_filter(focus_on_click) + + self.buffer = buffer or Buffer() + self.menu_position = menu_position + self.lexer = lexer or SimpleLexer() + self.key_bindings = key_bindings + self._search_buffer_control = search_buffer_control + + #: Cache for the lexer. + #: Often, due to cursor movement, undo/redo and window resizing + #: operations, it happens that a short time, the same document has to be + #: lexed. This is a fairly easy way to cache such an expensive operation. + self._fragment_cache: SimpleCache[ + Hashable, Callable[[int], StyleAndTextTuples] + ] = SimpleCache(maxsize=8) + + self._last_click_timestamp: float | None = None + self._last_get_processed_line: Callable[[int], _ProcessedLine] | None = None + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} buffer={self.buffer!r} at {id(self)!r}>" + + @property + def search_buffer_control(self) -> SearchBufferControl | None: + result: SearchBufferControl | None + + if callable(self._search_buffer_control): + result = self._search_buffer_control() + else: + result = self._search_buffer_control + + assert result is None or isinstance(result, SearchBufferControl) + return result + + @property + def search_buffer(self) -> Buffer | None: + control = self.search_buffer_control + if control is not None: + return control.buffer + return None + + @property + def search_state(self) -> SearchState: + """ + Return the `SearchState` for searching this `BufferControl`. This is + always associated with the search control. If one search bar is used + for searching multiple `BufferControls`, then they share the same + `SearchState`. + """ + search_buffer_control = self.search_buffer_control + if search_buffer_control: + return search_buffer_control.searcher_search_state + else: + return SearchState() + + def is_focusable(self) -> bool: + return self.focusable() + + def preferred_width(self, max_available_width: int) -> int | None: + """ + This should return the preferred width. + + Note: We don't specify a preferred width according to the content, + because it would be too expensive. Calculating the preferred + width can be done by calculating the longest line, but this would + require applying all the processors to each line. This is + unfeasible for a larger document, and doing it for small + documents only would result in inconsistent behavior. + """ + return None + + def preferred_height( + self, + width: int, + max_available_height: int, + wrap_lines: bool, + get_line_prefix: GetLinePrefixCallable | None, + ) -> int | None: + # Calculate the content height, if it was drawn on a screen with the + # given width. + height = 0 + content = self.create_content(width, height=1) # Pass a dummy '1' as height. + + # When line wrapping is off, the height should be equal to the amount + # of lines. + if not wrap_lines: + return content.line_count + + # When the number of lines exceeds the max_available_height, just + # return max_available_height. No need to calculate anything. + if content.line_count >= max_available_height: + return max_available_height + + for i in range(content.line_count): + height += content.get_height_for_line(i, width, get_line_prefix) + + if height >= max_available_height: + return max_available_height + + return height + + def _get_formatted_text_for_line_func( + self, document: Document + ) -> Callable[[int], StyleAndTextTuples]: + """ + Create a function that returns the fragments for a given line. + """ + + # Cache using `document.text`. + def get_formatted_text_for_line() -> Callable[[int], StyleAndTextTuples]: + return self.lexer.lex_document(document) + + key = (document.text, self.lexer.invalidation_hash()) + return self._fragment_cache.get(key, get_formatted_text_for_line) + + def _create_get_processed_line_func( + self, document: Document, width: int, height: int + ) -> Callable[[int], _ProcessedLine]: + """ + Create a function that takes a line number of the current document and + returns a _ProcessedLine(processed_fragments, source_to_display, display_to_source) + tuple. + """ + # Merge all input processors together. + input_processors = self.input_processors or [] + if self.include_default_input_processors: + input_processors = self.default_input_processors + input_processors + + merged_processor = merge_processors(input_processors) + + def transform(lineno: int, fragments: StyleAndTextTuples) -> _ProcessedLine: + "Transform the fragments for a given line number." + + # Get cursor position at this line. + def source_to_display(i: int) -> int: + """X position from the buffer to the x position in the + processed fragment list. By default, we start from the 'identity' + operation.""" + return i + + transformation = merged_processor.apply_transformation( + TransformationInput( + self, document, lineno, source_to_display, fragments, width, height + ) + ) + + return _ProcessedLine( + transformation.fragments, + transformation.source_to_display, + transformation.display_to_source, + ) + + def create_func() -> Callable[[int], _ProcessedLine]: + get_line = self._get_formatted_text_for_line_func(document) + cache: dict[int, _ProcessedLine] = {} + + def get_processed_line(i: int) -> _ProcessedLine: + try: + return cache[i] + except KeyError: + processed_line = transform(i, get_line(i)) + cache[i] = processed_line + return processed_line + + return get_processed_line + + return create_func() + + def create_content( + self, width: int, height: int, preview_search: bool = False + ) -> UIContent: + """ + Create a UIContent. + """ + buffer = self.buffer + + # Trigger history loading of the buffer. We do this during the + # rendering of the UI here, because it needs to happen when an + # `Application` with its event loop is running. During the rendering of + # the buffer control is the earliest place we can achieve this, where + # we're sure the right event loop is active, and don't require user + # interaction (like in a key binding). + buffer.load_history_if_not_yet_loaded() + + # Get the document to be shown. If we are currently searching (the + # search buffer has focus, and the preview_search filter is enabled), + # then use the search document, which has possibly a different + # text/cursor position.) + search_control = self.search_buffer_control + preview_now = preview_search or bool( + # Only if this feature is enabled. + self.preview_search() + and + # And something was typed in the associated search field. + search_control + and search_control.buffer.text + and + # And we are searching in this control. (Many controls can point to + # the same search field, like in Pyvim.) + get_app().layout.search_target_buffer_control == self + ) + + if preview_now and search_control is not None: + ss = self.search_state + + document = buffer.document_for_search( + SearchState( + text=search_control.buffer.text, + direction=ss.direction, + ignore_case=ss.ignore_case, + ) + ) + else: + document = buffer.document + + get_processed_line = self._create_get_processed_line_func( + document, width, height + ) + self._last_get_processed_line = get_processed_line + + def translate_rowcol(row: int, col: int) -> Point: + "Return the content column for this coordinate." + return Point(x=get_processed_line(row).source_to_display(col), y=row) + + def get_line(i: int) -> StyleAndTextTuples: + "Return the fragments for a given line number." + fragments = get_processed_line(i).fragments + + # Add a space at the end, because that is a possible cursor + # position. (When inserting after the input.) We should do this on + # all the lines, not just the line containing the cursor. (Because + # otherwise, line wrapping/scrolling could change when moving the + # cursor around.) + fragments = fragments + [("", " ")] + return fragments + + content = UIContent( + get_line=get_line, + line_count=document.line_count, + cursor_position=translate_rowcol( + document.cursor_position_row, document.cursor_position_col + ), + ) + + # If there is an auto completion going on, use that start point for a + # pop-up menu position. (But only when this buffer has the focus -- + # there is only one place for a menu, determined by the focused buffer.) + if get_app().layout.current_control == self: + menu_position = self.menu_position() if self.menu_position else None + if menu_position is not None: + assert isinstance(menu_position, int) + menu_row, menu_col = buffer.document.translate_index_to_position( + menu_position + ) + content.menu_position = translate_rowcol(menu_row, menu_col) + elif buffer.complete_state: + # Position for completion menu. + # Note: We use 'min', because the original cursor position could be + # behind the input string when the actual completion is for + # some reason shorter than the text we had before. (A completion + # can change and shorten the input.) + menu_row, menu_col = buffer.document.translate_index_to_position( + min( + buffer.cursor_position, + buffer.complete_state.original_document.cursor_position, + ) + ) + content.menu_position = translate_rowcol(menu_row, menu_col) + else: + content.menu_position = None + + return content + + def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone: + """ + Mouse handler for this control. + """ + buffer = self.buffer + position = mouse_event.position + + # Focus buffer when clicked. + if get_app().layout.current_control == self: + if self._last_get_processed_line: + processed_line = self._last_get_processed_line(position.y) + + # Translate coordinates back to the cursor position of the + # original input. + xpos = processed_line.display_to_source(position.x) + index = buffer.document.translate_row_col_to_index(position.y, xpos) + + # Set the cursor position. + if mouse_event.event_type == MouseEventType.MOUSE_DOWN: + buffer.exit_selection() + buffer.cursor_position = index + + elif ( + mouse_event.event_type == MouseEventType.MOUSE_MOVE + and mouse_event.button != MouseButton.NONE + ): + # Click and drag to highlight a selection + if ( + buffer.selection_state is None + and abs(buffer.cursor_position - index) > 0 + ): + buffer.start_selection(selection_type=SelectionType.CHARACTERS) + buffer.cursor_position = index + + elif mouse_event.event_type == MouseEventType.MOUSE_UP: + # When the cursor was moved to another place, select the text. + # (The >1 is actually a small but acceptable workaround for + # selecting text in Vi navigation mode. In navigation mode, + # the cursor can never be after the text, so the cursor + # will be repositioned automatically.) + if abs(buffer.cursor_position - index) > 1: + if buffer.selection_state is None: + buffer.start_selection( + selection_type=SelectionType.CHARACTERS + ) + buffer.cursor_position = index + + # Select word around cursor on double click. + # Two MOUSE_UP events in a short timespan are considered a double click. + double_click = ( + self._last_click_timestamp + and time.time() - self._last_click_timestamp < 0.3 + ) + self._last_click_timestamp = time.time() + + if double_click: + start, end = buffer.document.find_boundaries_of_current_word() + buffer.cursor_position += start + buffer.start_selection(selection_type=SelectionType.CHARACTERS) + buffer.cursor_position += end - start + else: + # Don't handle scroll events here. + return NotImplemented + + # Not focused, but focusing on click events. + else: + if ( + self.focus_on_click() + and mouse_event.event_type == MouseEventType.MOUSE_UP + ): + # Focus happens on mouseup. (If we did this on mousedown, the + # up event will be received at the point where this widget is + # focused and be handled anyway.) + get_app().layout.current_control = self + else: + return NotImplemented + + return None + + def move_cursor_down(self) -> None: + b = self.buffer + b.cursor_position += b.document.get_cursor_down_position() + + def move_cursor_up(self) -> None: + b = self.buffer + b.cursor_position += b.document.get_cursor_up_position() + + def get_key_bindings(self) -> KeyBindingsBase | None: + """ + When additional key bindings are given. Return these. + """ + return self.key_bindings + + def get_invalidate_events(self) -> Iterable[Event[object]]: + """ + Return the Window invalidate events. + """ + # Whenever the buffer changes, the UI has to be updated. + yield self.buffer.on_text_changed + yield self.buffer.on_cursor_position_changed + + yield self.buffer.on_completions_changed + yield self.buffer.on_suggestion_set + + +class SearchBufferControl(BufferControl): + """ + :class:`.BufferControl` which is used for searching another + :class:`.BufferControl`. + + :param ignore_case: Search case insensitive. + """ + + def __init__( + self, + buffer: Buffer | None = None, + input_processors: list[Processor] | None = None, + lexer: Lexer | None = None, + focus_on_click: FilterOrBool = False, + key_bindings: KeyBindingsBase | None = None, + ignore_case: FilterOrBool = False, + ): + super().__init__( + buffer=buffer, + input_processors=input_processors, + lexer=lexer, + focus_on_click=focus_on_click, + key_bindings=key_bindings, + ) + + # If this BufferControl is used as a search field for one or more other + # BufferControls, then represents the search state. + self.searcher_search_state = SearchState(ignore_case=ignore_case) diff --git a/src/prompt_toolkit/layout/dimension.py b/src/prompt_toolkit/layout/dimension.py new file mode 100644 index 0000000..c1f05f9 --- /dev/null +++ b/src/prompt_toolkit/layout/dimension.py @@ -0,0 +1,219 @@ +""" +Layout dimensions are used to give the minimum, maximum and preferred +dimensions for containers and controls. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable, Union + +__all__ = [ + "Dimension", + "D", + "sum_layout_dimensions", + "max_layout_dimensions", + "AnyDimension", + "to_dimension", + "is_dimension", +] + +if TYPE_CHECKING: + from typing_extensions import TypeGuard + + +class Dimension: + """ + Specified dimension (width/height) of a user control or window. + + The layout engine tries to honor the preferred size. If that is not + possible, because the terminal is larger or smaller, it tries to keep in + between min and max. + + :param min: Minimum size. + :param max: Maximum size. + :param weight: For a VSplit/HSplit, the actual size will be determined + by taking the proportion of weights from all the children. + E.g. When there are two children, one with a weight of 1, + and the other with a weight of 2, the second will always be + twice as big as the first, if the min/max values allow it. + :param preferred: Preferred size. + """ + + def __init__( + self, + min: int | None = None, + max: int | None = None, + weight: int | None = None, + preferred: int | None = None, + ) -> None: + if weight is not None: + assert weight >= 0 # Also cannot be a float. + + assert min is None or min >= 0 + assert max is None or max >= 0 + assert preferred is None or preferred >= 0 + + self.min_specified = min is not None + self.max_specified = max is not None + self.preferred_specified = preferred is not None + self.weight_specified = weight is not None + + if min is None: + min = 0 # Smallest possible value. + if max is None: # 0-values are allowed, so use "is None" + max = 1000**10 # Something huge. + if preferred is None: + preferred = min + if weight is None: + weight = 1 + + self.min = min + self.max = max + self.preferred = preferred + self.weight = weight + + # Don't allow situations where max < min. (This would be a bug.) + if max < min: + raise ValueError("Invalid Dimension: max < min.") + + # Make sure that the 'preferred' size is always in the min..max range. + if self.preferred < self.min: + self.preferred = self.min + + if self.preferred > self.max: + self.preferred = self.max + + @classmethod + def exact(cls, amount: int) -> Dimension: + """ + Return a :class:`.Dimension` with an exact size. (min, max and + preferred set to ``amount``). + """ + return cls(min=amount, max=amount, preferred=amount) + + @classmethod + def zero(cls) -> Dimension: + """ + Create a dimension that represents a zero size. (Used for 'invisible' + controls.) + """ + return cls.exact(amount=0) + + def is_zero(self) -> bool: + "True if this `Dimension` represents a zero size." + return self.preferred == 0 or self.max == 0 + + def __repr__(self) -> str: + fields = [] + if self.min_specified: + fields.append("min=%r" % self.min) + if self.max_specified: + fields.append("max=%r" % self.max) + if self.preferred_specified: + fields.append("preferred=%r" % self.preferred) + if self.weight_specified: + fields.append("weight=%r" % self.weight) + + return "Dimension(%s)" % ", ".join(fields) + + +def sum_layout_dimensions(dimensions: list[Dimension]) -> Dimension: + """ + Sum a list of :class:`.Dimension` instances. + """ + min = sum(d.min for d in dimensions) + max = sum(d.max for d in dimensions) + preferred = sum(d.preferred for d in dimensions) + + return Dimension(min=min, max=max, preferred=preferred) + + +def max_layout_dimensions(dimensions: list[Dimension]) -> Dimension: + """ + Take the maximum of a list of :class:`.Dimension` instances. + Used when we have a HSplit/VSplit, and we want to get the best width/height.) + """ + if not len(dimensions): + return Dimension.zero() + + # If all dimensions are size zero. Return zero. + # (This is important for HSplit/VSplit, to report the right values to their + # parent when all children are invisible.) + if all(d.is_zero() for d in dimensions): + return dimensions[0] + + # Ignore empty dimensions. (They should not reduce the size of others.) + dimensions = [d for d in dimensions if not d.is_zero()] + + if dimensions: + # Take the highest minimum dimension. + min_ = max(d.min for d in dimensions) + + # For the maximum, we would prefer not to go larger than then smallest + # 'max' value, unless other dimensions have a bigger preferred value. + # This seems to work best: + # - We don't want that a widget with a small height in a VSplit would + # shrink other widgets in the split. + # If it doesn't work well enough, then it's up to the UI designer to + # explicitly pass dimensions. + max_ = min(d.max for d in dimensions) + max_ = max(max_, max(d.preferred for d in dimensions)) + + # Make sure that min>=max. In some scenarios, when certain min..max + # ranges don't have any overlap, we can end up in such an impossible + # situation. In that case, give priority to the max value. + # E.g. taking (1..5) and (8..9) would return (8..5). Instead take (8..8). + if min_ > max_: + max_ = min_ + + preferred = max(d.preferred for d in dimensions) + + return Dimension(min=min_, max=max_, preferred=preferred) + else: + return Dimension() + + +# Anything that can be converted to a dimension. +AnyDimension = Union[ + None, # None is a valid dimension that will fit anything. + int, + Dimension, + # Callable[[], 'AnyDimension'] # Recursive definition not supported by mypy. + Callable[[], Any], +] + + +def to_dimension(value: AnyDimension) -> Dimension: + """ + Turn the given object into a `Dimension` object. + """ + if value is None: + return Dimension() + if isinstance(value, int): + return Dimension.exact(value) + if isinstance(value, Dimension): + return value + if callable(value): + return to_dimension(value()) + + raise ValueError("Not an integer or Dimension object.") + + +def is_dimension(value: object) -> TypeGuard[AnyDimension]: + """ + Test whether the given value could be a valid dimension. + (For usage in an assertion. It's not guaranteed in case of a callable.) + """ + if value is None: + return True + if callable(value): + return True # Assume it's a callable that doesn't take arguments. + if isinstance(value, (int, Dimension)): + return True + return False + + +# Common alias. +D = Dimension + +# For backward-compatibility. +LayoutDimension = Dimension diff --git a/src/prompt_toolkit/layout/dummy.py b/src/prompt_toolkit/layout/dummy.py new file mode 100644 index 0000000..139f311 --- /dev/null +++ b/src/prompt_toolkit/layout/dummy.py @@ -0,0 +1,39 @@ +""" +Dummy layout. Used when somebody creates an `Application` without specifying a +`Layout`. +""" +from __future__ import annotations + +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.key_binding.key_processor import KeyPressEvent + +from .containers import Window +from .controls import FormattedTextControl +from .dimension import D +from .layout import Layout + +__all__ = [ + "create_dummy_layout", +] + +E = KeyPressEvent + + +def create_dummy_layout() -> Layout: + """ + Create a dummy layout for use in an 'Application' that doesn't have a + layout specified. When ENTER is pressed, the application quits. + """ + kb = KeyBindings() + + @kb.add("enter") + def enter(event: E) -> None: + event.app.exit() + + control = FormattedTextControl( + HTML("No layout specified. Press <reverse>ENTER</reverse> to quit."), + key_bindings=kb, + ) + window = Window(content=control, height=D(min=1)) + return Layout(container=window, focused_element=window) diff --git a/src/prompt_toolkit/layout/layout.py b/src/prompt_toolkit/layout/layout.py new file mode 100644 index 0000000..a5e7a80 --- /dev/null +++ b/src/prompt_toolkit/layout/layout.py @@ -0,0 +1,411 @@ +""" +Wrapper for the layout. +""" +from __future__ import annotations + +from typing import Generator, Iterable, Union + +from prompt_toolkit.buffer import Buffer + +from .containers import ( + AnyContainer, + ConditionalContainer, + Container, + Window, + to_container, +) +from .controls import BufferControl, SearchBufferControl, UIControl + +__all__ = [ + "Layout", + "InvalidLayoutError", + "walk", +] + +FocusableElement = Union[str, Buffer, UIControl, AnyContainer] + + +class Layout: + """ + The layout for a prompt_toolkit + :class:`~prompt_toolkit.application.Application`. + This also keeps track of which user control is focused. + + :param container: The "root" container for the layout. + :param focused_element: element to be focused initially. (Can be anything + the `focus` function accepts.) + """ + + def __init__( + self, + container: AnyContainer, + focused_element: FocusableElement | None = None, + ) -> None: + self.container = to_container(container) + self._stack: list[Window] = [] + + # Map search BufferControl back to the original BufferControl. + # This is used to keep track of when exactly we are searching, and for + # applying the search. + # When a link exists in this dictionary, that means the search is + # currently active. + # Map: search_buffer_control -> original buffer control. + self.search_links: dict[SearchBufferControl, BufferControl] = {} + + # Mapping that maps the children in the layout to their parent. + # This relationship is calculated dynamically, each time when the UI + # is rendered. (UI elements have only references to their children.) + self._child_to_parent: dict[Container, Container] = {} + + if focused_element is None: + try: + self._stack.append(next(self.find_all_windows())) + except StopIteration as e: + raise InvalidLayoutError( + "Invalid layout. The layout does not contain any Window object." + ) from e + else: + self.focus(focused_element) + + # List of visible windows. + self.visible_windows: list[Window] = [] # List of `Window` objects. + + def __repr__(self) -> str: + return f"Layout({self.container!r}, current_window={self.current_window!r})" + + def find_all_windows(self) -> Generator[Window, None, None]: + """ + Find all the :class:`.UIControl` objects in this layout. + """ + for item in self.walk(): + if isinstance(item, Window): + yield item + + def find_all_controls(self) -> Iterable[UIControl]: + for container in self.find_all_windows(): + yield container.content + + def focus(self, value: FocusableElement) -> None: + """ + Focus the given UI element. + + `value` can be either: + + - a :class:`.UIControl` + - a :class:`.Buffer` instance or the name of a :class:`.Buffer` + - a :class:`.Window` + - Any container object. In this case we will focus the :class:`.Window` + from this container that was focused most recent, or the very first + focusable :class:`.Window` of the container. + """ + # BufferControl by buffer name. + if isinstance(value, str): + for control in self.find_all_controls(): + if isinstance(control, BufferControl) and control.buffer.name == value: + self.focus(control) + return + raise ValueError(f"Couldn't find Buffer in the current layout: {value!r}.") + + # BufferControl by buffer object. + elif isinstance(value, Buffer): + for control in self.find_all_controls(): + if isinstance(control, BufferControl) and control.buffer == value: + self.focus(control) + return + raise ValueError(f"Couldn't find Buffer in the current layout: {value!r}.") + + # Focus UIControl. + elif isinstance(value, UIControl): + if value not in self.find_all_controls(): + raise ValueError( + "Invalid value. Container does not appear in the layout." + ) + if not value.is_focusable(): + raise ValueError("Invalid value. UIControl is not focusable.") + + self.current_control = value + + # Otherwise, expecting any Container object. + else: + value = to_container(value) + + if isinstance(value, Window): + # This is a `Window`: focus that. + if value not in self.find_all_windows(): + raise ValueError( + f"Invalid value. Window does not appear in the layout: {value!r}" + ) + + self.current_window = value + else: + # Focus a window in this container. + # If we have many windows as part of this container, and some + # of them have been focused before, take the last focused + # item. (This is very useful when the UI is composed of more + # complex sub components.) + windows = [] + for c in walk(value, skip_hidden=True): + if isinstance(c, Window) and c.content.is_focusable(): + windows.append(c) + + # Take the first one that was focused before. + for w in reversed(self._stack): + if w in windows: + self.current_window = w + return + + # None was focused before: take the very first focusable window. + if windows: + self.current_window = windows[0] + return + + raise ValueError( + f"Invalid value. Container cannot be focused: {value!r}" + ) + + def has_focus(self, value: FocusableElement) -> bool: + """ + Check whether the given control has the focus. + :param value: :class:`.UIControl` or :class:`.Window` instance. + """ + if isinstance(value, str): + if self.current_buffer is None: + return False + return self.current_buffer.name == value + if isinstance(value, Buffer): + return self.current_buffer == value + if isinstance(value, UIControl): + return self.current_control == value + else: + value = to_container(value) + if isinstance(value, Window): + return self.current_window == value + else: + # Check whether this "container" is focused. This is true if + # one of the elements inside is focused. + for element in walk(value): + if element == self.current_window: + return True + return False + + @property + def current_control(self) -> UIControl: + """ + Get the :class:`.UIControl` to currently has the focus. + """ + return self._stack[-1].content + + @current_control.setter + def current_control(self, control: UIControl) -> None: + """ + Set the :class:`.UIControl` to receive the focus. + """ + for window in self.find_all_windows(): + if window.content == control: + self.current_window = window + return + + raise ValueError("Control not found in the user interface.") + + @property + def current_window(self) -> Window: + "Return the :class:`.Window` object that is currently focused." + return self._stack[-1] + + @current_window.setter + def current_window(self, value: Window) -> None: + "Set the :class:`.Window` object to be currently focused." + self._stack.append(value) + + @property + def is_searching(self) -> bool: + "True if we are searching right now." + return self.current_control in self.search_links + + @property + def search_target_buffer_control(self) -> BufferControl | None: + """ + Return the :class:`.BufferControl` in which we are searching or `None`. + """ + # Not every `UIControl` is a `BufferControl`. This only applies to + # `BufferControl`. + control = self.current_control + + if isinstance(control, SearchBufferControl): + return self.search_links.get(control) + else: + return None + + def get_focusable_windows(self) -> Iterable[Window]: + """ + Return all the :class:`.Window` objects which are focusable (in the + 'modal' area). + """ + for w in self.walk_through_modal_area(): + if isinstance(w, Window) and w.content.is_focusable(): + yield w + + def get_visible_focusable_windows(self) -> list[Window]: + """ + Return a list of :class:`.Window` objects that are focusable. + """ + # focusable windows are windows that are visible, but also part of the + # modal container. Make sure to keep the ordering. + visible_windows = self.visible_windows + return [w for w in self.get_focusable_windows() if w in visible_windows] + + @property + def current_buffer(self) -> Buffer | None: + """ + The currently focused :class:`~.Buffer` or `None`. + """ + ui_control = self.current_control + if isinstance(ui_control, BufferControl): + return ui_control.buffer + return None + + def get_buffer_by_name(self, buffer_name: str) -> Buffer | None: + """ + Look in the layout for a buffer with the given name. + Return `None` when nothing was found. + """ + for w in self.walk(): + if isinstance(w, Window) and isinstance(w.content, BufferControl): + if w.content.buffer.name == buffer_name: + return w.content.buffer + return None + + @property + def buffer_has_focus(self) -> bool: + """ + Return `True` if the currently focused control is a + :class:`.BufferControl`. (For instance, used to determine whether the + default key bindings should be active or not.) + """ + ui_control = self.current_control + return isinstance(ui_control, BufferControl) + + @property + def previous_control(self) -> UIControl: + """ + Get the :class:`.UIControl` to previously had the focus. + """ + try: + return self._stack[-2].content + except IndexError: + return self._stack[-1].content + + def focus_last(self) -> None: + """ + Give the focus to the last focused control. + """ + if len(self._stack) > 1: + self._stack = self._stack[:-1] + + def focus_next(self) -> None: + """ + Focus the next visible/focusable Window. + """ + windows = self.get_visible_focusable_windows() + + if len(windows) > 0: + try: + index = windows.index(self.current_window) + except ValueError: + index = 0 + else: + index = (index + 1) % len(windows) + + self.focus(windows[index]) + + def focus_previous(self) -> None: + """ + Focus the previous visible/focusable Window. + """ + windows = self.get_visible_focusable_windows() + + if len(windows) > 0: + try: + index = windows.index(self.current_window) + except ValueError: + index = 0 + else: + index = (index - 1) % len(windows) + + self.focus(windows[index]) + + def walk(self) -> Iterable[Container]: + """ + Walk through all the layout nodes (and their children) and yield them. + """ + yield from walk(self.container) + + def walk_through_modal_area(self) -> Iterable[Container]: + """ + Walk through all the containers which are in the current 'modal' part + of the layout. + """ + # Go up in the tree, and find the root. (it will be a part of the + # layout, if the focus is in a modal part.) + root: Container = self.current_window + while not root.is_modal() and root in self._child_to_parent: + root = self._child_to_parent[root] + + yield from walk(root) + + def update_parents_relations(self) -> None: + """ + Update child->parent relationships mapping. + """ + parents = {} + + def walk(e: Container) -> None: + for c in e.get_children(): + parents[c] = e + walk(c) + + walk(self.container) + + self._child_to_parent = parents + + def reset(self) -> None: + # Remove all search links when the UI starts. + # (Important, for instance when control-c is been pressed while + # searching. The prompt cancels, but next `run()` call the search + # links are still there.) + self.search_links.clear() + + self.container.reset() + + def get_parent(self, container: Container) -> Container | None: + """ + Return the parent container for the given container, or ``None``, if it + wasn't found. + """ + try: + return self._child_to_parent[container] + except KeyError: + return None + + +class InvalidLayoutError(Exception): + pass + + +def walk(container: Container, skip_hidden: bool = False) -> Iterable[Container]: + """ + Walk through layout, starting at this container. + """ + # When `skip_hidden` is set, don't go into disabled ConditionalContainer containers. + if ( + skip_hidden + and isinstance(container, ConditionalContainer) + and not container.filter() + ): + return + + yield container + + for c in container.get_children(): + # yield from walk(c) + yield from walk(c, skip_hidden=skip_hidden) diff --git a/src/prompt_toolkit/layout/margins.py b/src/prompt_toolkit/layout/margins.py new file mode 100644 index 0000000..cc9dd96 --- /dev/null +++ b/src/prompt_toolkit/layout/margins.py @@ -0,0 +1,303 @@ +""" +Margin implementations for a :class:`~prompt_toolkit.layout.containers.Window`. +""" +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from typing import TYPE_CHECKING, Callable + +from prompt_toolkit.filters import FilterOrBool, to_filter +from prompt_toolkit.formatted_text import ( + StyleAndTextTuples, + fragment_list_to_text, + to_formatted_text, +) +from prompt_toolkit.utils import get_cwidth + +from .controls import UIContent + +if TYPE_CHECKING: + from .containers import WindowRenderInfo + +__all__ = [ + "Margin", + "NumberedMargin", + "ScrollbarMargin", + "ConditionalMargin", + "PromptMargin", +] + + +class Margin(metaclass=ABCMeta): + """ + Base interface for a margin. + """ + + @abstractmethod + def get_width(self, get_ui_content: Callable[[], UIContent]) -> int: + """ + Return the width that this margin is going to consume. + + :param get_ui_content: Callable that asks the user control to create + a :class:`.UIContent` instance. This can be used for instance to + obtain the number of lines. + """ + return 0 + + @abstractmethod + def create_margin( + self, window_render_info: WindowRenderInfo, width: int, height: int + ) -> StyleAndTextTuples: + """ + Creates a margin. + This should return a list of (style_str, text) tuples. + + :param window_render_info: + :class:`~prompt_toolkit.layout.containers.WindowRenderInfo` + instance, generated after rendering and copying the visible part of + the :class:`~prompt_toolkit.layout.controls.UIControl` into the + :class:`~prompt_toolkit.layout.containers.Window`. + :param width: The width that's available for this margin. (As reported + by :meth:`.get_width`.) + :param height: The height that's available for this margin. (The height + of the :class:`~prompt_toolkit.layout.containers.Window`.) + """ + return [] + + +class NumberedMargin(Margin): + """ + Margin that displays the line numbers. + + :param relative: Number relative to the cursor position. Similar to the Vi + 'relativenumber' option. + :param display_tildes: Display tildes after the end of the document, just + like Vi does. + """ + + def __init__( + self, relative: FilterOrBool = False, display_tildes: FilterOrBool = False + ) -> None: + self.relative = to_filter(relative) + self.display_tildes = to_filter(display_tildes) + + def get_width(self, get_ui_content: Callable[[], UIContent]) -> int: + line_count = get_ui_content().line_count + return max(3, len("%s" % line_count) + 1) + + def create_margin( + self, window_render_info: WindowRenderInfo, width: int, height: int + ) -> StyleAndTextTuples: + relative = self.relative() + + style = "class:line-number" + style_current = "class:line-number.current" + + # Get current line number. + current_lineno = window_render_info.ui_content.cursor_position.y + + # Construct margin. + result: StyleAndTextTuples = [] + last_lineno = None + + for y, lineno in enumerate(window_render_info.displayed_lines): + # Only display line number if this line is not a continuation of the previous line. + if lineno != last_lineno: + if lineno is None: + pass + elif lineno == current_lineno: + # Current line. + if relative: + # Left align current number in relative mode. + result.append((style_current, "%i" % (lineno + 1))) + else: + result.append( + (style_current, ("%i " % (lineno + 1)).rjust(width)) + ) + else: + # Other lines. + if relative: + lineno = abs(lineno - current_lineno) - 1 + + result.append((style, ("%i " % (lineno + 1)).rjust(width))) + + last_lineno = lineno + result.append(("", "\n")) + + # Fill with tildes. + if self.display_tildes(): + while y < window_render_info.window_height: + result.append(("class:tilde", "~\n")) + y += 1 + + return result + + +class ConditionalMargin(Margin): + """ + Wrapper around other :class:`.Margin` classes to show/hide them. + """ + + def __init__(self, margin: Margin, filter: FilterOrBool) -> None: + self.margin = margin + self.filter = to_filter(filter) + + def get_width(self, get_ui_content: Callable[[], UIContent]) -> int: + if self.filter(): + return self.margin.get_width(get_ui_content) + else: + return 0 + + def create_margin( + self, window_render_info: WindowRenderInfo, width: int, height: int + ) -> StyleAndTextTuples: + if width and self.filter(): + return self.margin.create_margin(window_render_info, width, height) + else: + return [] + + +class ScrollbarMargin(Margin): + """ + Margin displaying a scrollbar. + + :param display_arrows: Display scroll up/down arrows. + """ + + def __init__( + self, + display_arrows: FilterOrBool = False, + up_arrow_symbol: str = "^", + down_arrow_symbol: str = "v", + ) -> None: + self.display_arrows = to_filter(display_arrows) + self.up_arrow_symbol = up_arrow_symbol + self.down_arrow_symbol = down_arrow_symbol + + def get_width(self, get_ui_content: Callable[[], UIContent]) -> int: + return 1 + + def create_margin( + self, window_render_info: WindowRenderInfo, width: int, height: int + ) -> StyleAndTextTuples: + content_height = window_render_info.content_height + window_height = window_render_info.window_height + display_arrows = self.display_arrows() + + if display_arrows: + window_height -= 2 + + try: + fraction_visible = len(window_render_info.displayed_lines) / float( + content_height + ) + fraction_above = window_render_info.vertical_scroll / float(content_height) + + scrollbar_height = int( + min(window_height, max(1, window_height * fraction_visible)) + ) + scrollbar_top = int(window_height * fraction_above) + except ZeroDivisionError: + return [] + else: + + def is_scroll_button(row: int) -> bool: + "True if we should display a button on this row." + return scrollbar_top <= row <= scrollbar_top + scrollbar_height + + # Up arrow. + result: StyleAndTextTuples = [] + if display_arrows: + result.extend( + [ + ("class:scrollbar.arrow", self.up_arrow_symbol), + ("class:scrollbar", "\n"), + ] + ) + + # Scrollbar body. + scrollbar_background = "class:scrollbar.background" + scrollbar_background_start = "class:scrollbar.background,scrollbar.start" + scrollbar_button = "class:scrollbar.button" + scrollbar_button_end = "class:scrollbar.button,scrollbar.end" + + for i in range(window_height): + if is_scroll_button(i): + if not is_scroll_button(i + 1): + # Give the last cell a different style, because we + # want to underline this. + result.append((scrollbar_button_end, " ")) + else: + result.append((scrollbar_button, " ")) + else: + if is_scroll_button(i + 1): + result.append((scrollbar_background_start, " ")) + else: + result.append((scrollbar_background, " ")) + result.append(("", "\n")) + + # Down arrow + if display_arrows: + result.append(("class:scrollbar.arrow", self.down_arrow_symbol)) + + return result + + +class PromptMargin(Margin): + """ + [Deprecated] + + Create margin that displays a prompt. + This can display one prompt at the first line, and a continuation prompt + (e.g, just dots) on all the following lines. + + This `PromptMargin` implementation has been largely superseded in favor of + the `get_line_prefix` attribute of `Window`. The reason is that a margin is + always a fixed width, while `get_line_prefix` can return a variable width + prefix in front of every line, making it more powerful, especially for line + continuations. + + :param get_prompt: Callable returns formatted text or a list of + `(style_str, type)` tuples to be shown as the prompt at the first line. + :param get_continuation: Callable that takes three inputs. The width (int), + line_number (int), and is_soft_wrap (bool). It should return formatted + text or a list of `(style_str, type)` tuples for the next lines of the + input. + """ + + def __init__( + self, + get_prompt: Callable[[], StyleAndTextTuples], + get_continuation: None + | (Callable[[int, int, bool], StyleAndTextTuples]) = None, + ) -> None: + self.get_prompt = get_prompt + self.get_continuation = get_continuation + + def get_width(self, get_ui_content: Callable[[], UIContent]) -> int: + "Width to report to the `Window`." + # Take the width from the first line. + text = fragment_list_to_text(self.get_prompt()) + return get_cwidth(text) + + def create_margin( + self, window_render_info: WindowRenderInfo, width: int, height: int + ) -> StyleAndTextTuples: + get_continuation = self.get_continuation + result: StyleAndTextTuples = [] + + # First line. + result.extend(to_formatted_text(self.get_prompt())) + + # Next lines. + if get_continuation: + last_y = None + + for y in window_render_info.displayed_lines[1:]: + result.append(("", "\n")) + result.extend( + to_formatted_text(get_continuation(width, y, y == last_y)) + ) + last_y = y + + return result diff --git a/src/prompt_toolkit/layout/menus.py b/src/prompt_toolkit/layout/menus.py new file mode 100644 index 0000000..2c2ccb6 --- /dev/null +++ b/src/prompt_toolkit/layout/menus.py @@ -0,0 +1,751 @@ +from __future__ import annotations + +import math +from itertools import zip_longest +from typing import TYPE_CHECKING, Callable, Iterable, Sequence, TypeVar, cast +from weakref import WeakKeyDictionary + +from prompt_toolkit.application.current import get_app +from prompt_toolkit.buffer import CompletionState +from prompt_toolkit.completion import Completion +from prompt_toolkit.data_structures import Point +from prompt_toolkit.filters import ( + Condition, + FilterOrBool, + has_completions, + is_done, + to_filter, +) +from prompt_toolkit.formatted_text import ( + StyleAndTextTuples, + fragment_list_width, + to_formatted_text, +) +from prompt_toolkit.key_binding.key_processor import KeyPressEvent +from prompt_toolkit.layout.utils import explode_text_fragments +from prompt_toolkit.mouse_events import MouseEvent, MouseEventType +from prompt_toolkit.utils import get_cwidth + +from .containers import ConditionalContainer, HSplit, ScrollOffsets, Window +from .controls import GetLinePrefixCallable, UIContent, UIControl +from .dimension import Dimension +from .margins import ScrollbarMargin + +if TYPE_CHECKING: + from prompt_toolkit.key_binding.key_bindings import ( + KeyBindings, + NotImplementedOrNone, + ) + + +__all__ = [ + "CompletionsMenu", + "MultiColumnCompletionsMenu", +] + +E = KeyPressEvent + + +class CompletionsMenuControl(UIControl): + """ + Helper for drawing the complete menu to the screen. + + :param scroll_offset: Number (integer) representing the preferred amount of + completions to be displayed before and after the current one. When this + is a very high number, the current completion will be shown in the + middle most of the time. + """ + + # Preferred minimum size of the menu control. + # The CompletionsMenu class defines a width of 8, and there is a scrollbar + # of 1.) + MIN_WIDTH = 7 + + def has_focus(self) -> bool: + return False + + def preferred_width(self, max_available_width: int) -> int | None: + complete_state = get_app().current_buffer.complete_state + if complete_state: + menu_width = self._get_menu_width(500, complete_state) + menu_meta_width = self._get_menu_meta_width(500, complete_state) + + return menu_width + menu_meta_width + else: + return 0 + + def preferred_height( + self, + width: int, + max_available_height: int, + wrap_lines: bool, + get_line_prefix: GetLinePrefixCallable | None, + ) -> int | None: + complete_state = get_app().current_buffer.complete_state + if complete_state: + return len(complete_state.completions) + else: + return 0 + + def create_content(self, width: int, height: int) -> UIContent: + """ + Create a UIContent object for this control. + """ + complete_state = get_app().current_buffer.complete_state + if complete_state: + completions = complete_state.completions + index = complete_state.complete_index # Can be None! + + # Calculate width of completions menu. + menu_width = self._get_menu_width(width, complete_state) + menu_meta_width = self._get_menu_meta_width( + width - menu_width, complete_state + ) + show_meta = self._show_meta(complete_state) + + def get_line(i: int) -> StyleAndTextTuples: + c = completions[i] + is_current_completion = i == index + result = _get_menu_item_fragments( + c, is_current_completion, menu_width, space_after=True + ) + + if show_meta: + result += self._get_menu_item_meta_fragments( + c, is_current_completion, menu_meta_width + ) + return result + + return UIContent( + get_line=get_line, + cursor_position=Point(x=0, y=index or 0), + line_count=len(completions), + ) + + return UIContent() + + def _show_meta(self, complete_state: CompletionState) -> bool: + """ + Return ``True`` if we need to show a column with meta information. + """ + return any(c.display_meta_text for c in complete_state.completions) + + def _get_menu_width(self, max_width: int, complete_state: CompletionState) -> int: + """ + Return the width of the main column. + """ + return min( + max_width, + max( + self.MIN_WIDTH, + max(get_cwidth(c.display_text) for c in complete_state.completions) + 2, + ), + ) + + def _get_menu_meta_width( + self, max_width: int, complete_state: CompletionState + ) -> int: + """ + Return the width of the meta column. + """ + + def meta_width(completion: Completion) -> int: + return get_cwidth(completion.display_meta_text) + + if self._show_meta(complete_state): + # If the amount of completions is over 200, compute the width based + # on the first 200 completions, otherwise this can be very slow. + completions = complete_state.completions + if len(completions) > 200: + completions = completions[:200] + + return min(max_width, max(meta_width(c) for c in completions) + 2) + else: + return 0 + + def _get_menu_item_meta_fragments( + self, completion: Completion, is_current_completion: bool, width: int + ) -> StyleAndTextTuples: + if is_current_completion: + style_str = "class:completion-menu.meta.completion.current" + else: + style_str = "class:completion-menu.meta.completion" + + text, tw = _trim_formatted_text(completion.display_meta, width - 2) + padding = " " * (width - 1 - tw) + + return to_formatted_text( + cast(StyleAndTextTuples, []) + [("", " ")] + text + [("", padding)], + style=style_str, + ) + + def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone: + """ + Handle mouse events: clicking and scrolling. + """ + b = get_app().current_buffer + + if mouse_event.event_type == MouseEventType.MOUSE_UP: + # Select completion. + b.go_to_completion(mouse_event.position.y) + b.complete_state = None + + elif mouse_event.event_type == MouseEventType.SCROLL_DOWN: + # Scroll up. + b.complete_next(count=3, disable_wrap_around=True) + + elif mouse_event.event_type == MouseEventType.SCROLL_UP: + # Scroll down. + b.complete_previous(count=3, disable_wrap_around=True) + + return None + + +def _get_menu_item_fragments( + completion: Completion, + is_current_completion: bool, + width: int, + space_after: bool = False, +) -> StyleAndTextTuples: + """ + Get the style/text tuples for a menu item, styled and trimmed to the given + width. + """ + if is_current_completion: + style_str = "class:completion-menu.completion.current {} {}".format( + completion.style, + completion.selected_style, + ) + else: + style_str = "class:completion-menu.completion " + completion.style + + text, tw = _trim_formatted_text( + completion.display, (width - 2 if space_after else width - 1) + ) + + padding = " " * (width - 1 - tw) + + return to_formatted_text( + cast(StyleAndTextTuples, []) + [("", " ")] + text + [("", padding)], + style=style_str, + ) + + +def _trim_formatted_text( + formatted_text: StyleAndTextTuples, max_width: int +) -> tuple[StyleAndTextTuples, int]: + """ + Trim the text to `max_width`, append dots when the text is too long. + Returns (text, width) tuple. + """ + width = fragment_list_width(formatted_text) + + # When the text is too wide, trim it. + if width > max_width: + result = [] # Text fragments. + remaining_width = max_width - 3 + + for style_and_ch in explode_text_fragments(formatted_text): + ch_width = get_cwidth(style_and_ch[1]) + + if ch_width <= remaining_width: + result.append(style_and_ch) + remaining_width -= ch_width + else: + break + + result.append(("", "...")) + + return result, max_width - remaining_width + else: + return formatted_text, width + + +class CompletionsMenu(ConditionalContainer): + # NOTE: We use a pretty big z_index by default. Menus are supposed to be + # above anything else. We also want to make sure that the content is + # visible at the point where we draw this menu. + def __init__( + self, + max_height: int | None = None, + scroll_offset: int | Callable[[], int] = 0, + extra_filter: FilterOrBool = True, + display_arrows: FilterOrBool = False, + z_index: int = 10**8, + ) -> None: + extra_filter = to_filter(extra_filter) + display_arrows = to_filter(display_arrows) + + super().__init__( + content=Window( + content=CompletionsMenuControl(), + width=Dimension(min=8), + height=Dimension(min=1, max=max_height), + scroll_offsets=ScrollOffsets(top=scroll_offset, bottom=scroll_offset), + right_margins=[ScrollbarMargin(display_arrows=display_arrows)], + dont_extend_width=True, + style="class:completion-menu", + z_index=z_index, + ), + # Show when there are completions but not at the point we are + # returning the input. + filter=extra_filter & has_completions & ~is_done, + ) + + +class MultiColumnCompletionMenuControl(UIControl): + """ + Completion menu that displays all the completions in several columns. + When there are more completions than space for them to be displayed, an + arrow is shown on the left or right side. + + `min_rows` indicates how many rows will be available in any possible case. + When this is larger than one, it will try to use less columns and more + rows until this value is reached. + Be careful passing in a too big value, if less than the given amount of + rows are available, more columns would have been required, but + `preferred_width` doesn't know about that and reports a too small value. + This results in less completions displayed and additional scrolling. + (It's a limitation of how the layout engine currently works: first the + widths are calculated, then the heights.) + + :param suggested_max_column_width: The suggested max width of a column. + The column can still be bigger than this, but if there is place for two + columns of this width, we will display two columns. This to avoid that + if there is one very wide completion, that it doesn't significantly + reduce the amount of columns. + """ + + _required_margin = 3 # One extra padding on the right + space for arrows. + + def __init__(self, min_rows: int = 3, suggested_max_column_width: int = 30) -> None: + assert min_rows >= 1 + + self.min_rows = min_rows + self.suggested_max_column_width = suggested_max_column_width + self.scroll = 0 + + # Cache for column width computations. This computation is not cheap, + # so we don't want to do it over and over again while the user + # navigates through the completions. + # (map `completion_state` to `(completion_count, width)`. We remember + # the count, because a completer can add new completions to the + # `CompletionState` while loading.) + self._column_width_for_completion_state: WeakKeyDictionary[ + CompletionState, tuple[int, int] + ] = WeakKeyDictionary() + + # Info of last rendering. + self._rendered_rows = 0 + self._rendered_columns = 0 + self._total_columns = 0 + self._render_pos_to_completion: dict[tuple[int, int], Completion] = {} + self._render_left_arrow = False + self._render_right_arrow = False + self._render_width = 0 + + def reset(self) -> None: + self.scroll = 0 + + def has_focus(self) -> bool: + return False + + def preferred_width(self, max_available_width: int) -> int | None: + """ + Preferred width: prefer to use at least min_rows, but otherwise as much + as possible horizontally. + """ + complete_state = get_app().current_buffer.complete_state + if complete_state is None: + return 0 + + column_width = self._get_column_width(complete_state) + result = int( + column_width + * math.ceil(len(complete_state.completions) / float(self.min_rows)) + ) + + # When the desired width is still more than the maximum available, + # reduce by removing columns until we are less than the available + # width. + while ( + result > column_width + and result > max_available_width - self._required_margin + ): + result -= column_width + return result + self._required_margin + + def preferred_height( + self, + width: int, + max_available_height: int, + wrap_lines: bool, + get_line_prefix: GetLinePrefixCallable | None, + ) -> int | None: + """ + Preferred height: as much as needed in order to display all the completions. + """ + complete_state = get_app().current_buffer.complete_state + if complete_state is None: + return 0 + + column_width = self._get_column_width(complete_state) + column_count = max(1, (width - self._required_margin) // column_width) + + return int(math.ceil(len(complete_state.completions) / float(column_count))) + + def create_content(self, width: int, height: int) -> UIContent: + """ + Create a UIContent object for this menu. + """ + complete_state = get_app().current_buffer.complete_state + if complete_state is None: + return UIContent() + + column_width = self._get_column_width(complete_state) + self._render_pos_to_completion = {} + + _T = TypeVar("_T") + + def grouper( + n: int, iterable: Iterable[_T], fillvalue: _T | None = None + ) -> Iterable[Sequence[_T | None]]: + "grouper(3, 'ABCDEFG', 'x') --> ABC DEF Gxx" + args = [iter(iterable)] * n + return zip_longest(fillvalue=fillvalue, *args) + + def is_current_completion(completion: Completion) -> bool: + "Returns True when this completion is the currently selected one." + return ( + complete_state is not None + and complete_state.complete_index is not None + and c == complete_state.current_completion + ) + + # Space required outside of the regular columns, for displaying the + # left and right arrow. + HORIZONTAL_MARGIN_REQUIRED = 3 + + # There should be at least one column, but it cannot be wider than + # the available width. + column_width = min(width - HORIZONTAL_MARGIN_REQUIRED, column_width) + + # However, when the columns tend to be very wide, because there are + # some very wide entries, shrink it anyway. + if column_width > self.suggested_max_column_width: + # `column_width` can still be bigger that `suggested_max_column_width`, + # but if there is place for two columns, we divide by two. + column_width //= column_width // self.suggested_max_column_width + + visible_columns = max(1, (width - self._required_margin) // column_width) + + columns_ = list(grouper(height, complete_state.completions)) + rows_ = list(zip(*columns_)) + + # Make sure the current completion is always visible: update scroll offset. + selected_column = (complete_state.complete_index or 0) // height + self.scroll = min( + selected_column, max(self.scroll, selected_column - visible_columns + 1) + ) + + render_left_arrow = self.scroll > 0 + render_right_arrow = self.scroll < len(rows_[0]) - visible_columns + + # Write completions to screen. + fragments_for_line = [] + + for row_index, row in enumerate(rows_): + fragments: StyleAndTextTuples = [] + middle_row = row_index == len(rows_) // 2 + + # Draw left arrow if we have hidden completions on the left. + if render_left_arrow: + fragments.append(("class:scrollbar", "<" if middle_row else " ")) + elif render_right_arrow: + # Reserve one column empty space. (If there is a right + # arrow right now, there can be a left arrow as well.) + fragments.append(("", " ")) + + # Draw row content. + for column_index, c in enumerate(row[self.scroll :][:visible_columns]): + if c is not None: + fragments += _get_menu_item_fragments( + c, is_current_completion(c), column_width, space_after=False + ) + + # Remember render position for mouse click handler. + for x in range(column_width): + self._render_pos_to_completion[ + (column_index * column_width + x, row_index) + ] = c + else: + fragments.append(("class:completion", " " * column_width)) + + # Draw trailing padding for this row. + # (_get_menu_item_fragments only returns padding on the left.) + if render_left_arrow or render_right_arrow: + fragments.append(("class:completion", " ")) + + # Draw right arrow if we have hidden completions on the right. + if render_right_arrow: + fragments.append(("class:scrollbar", ">" if middle_row else " ")) + elif render_left_arrow: + fragments.append(("class:completion", " ")) + + # Add line. + fragments_for_line.append( + to_formatted_text(fragments, style="class:completion-menu") + ) + + self._rendered_rows = height + self._rendered_columns = visible_columns + self._total_columns = len(columns_) + self._render_left_arrow = render_left_arrow + self._render_right_arrow = render_right_arrow + self._render_width = ( + column_width * visible_columns + render_left_arrow + render_right_arrow + 1 + ) + + def get_line(i: int) -> StyleAndTextTuples: + return fragments_for_line[i] + + return UIContent(get_line=get_line, line_count=len(rows_)) + + def _get_column_width(self, completion_state: CompletionState) -> int: + """ + Return the width of each column. + """ + try: + count, width = self._column_width_for_completion_state[completion_state] + if count != len(completion_state.completions): + # Number of completions changed, recompute. + raise KeyError + return width + except KeyError: + result = ( + max(get_cwidth(c.display_text) for c in completion_state.completions) + + 1 + ) + self._column_width_for_completion_state[completion_state] = ( + len(completion_state.completions), + result, + ) + return result + + def mouse_handler(self, mouse_event: MouseEvent) -> NotImplementedOrNone: + """ + Handle scroll and click events. + """ + b = get_app().current_buffer + + def scroll_left() -> None: + b.complete_previous(count=self._rendered_rows, disable_wrap_around=True) + self.scroll = max(0, self.scroll - 1) + + def scroll_right() -> None: + b.complete_next(count=self._rendered_rows, disable_wrap_around=True) + self.scroll = min( + self._total_columns - self._rendered_columns, self.scroll + 1 + ) + + if mouse_event.event_type == MouseEventType.SCROLL_DOWN: + scroll_right() + + elif mouse_event.event_type == MouseEventType.SCROLL_UP: + scroll_left() + + elif mouse_event.event_type == MouseEventType.MOUSE_UP: + x = mouse_event.position.x + y = mouse_event.position.y + + # Mouse click on left arrow. + if x == 0: + if self._render_left_arrow: + scroll_left() + + # Mouse click on right arrow. + elif x == self._render_width - 1: + if self._render_right_arrow: + scroll_right() + + # Mouse click on completion. + else: + completion = self._render_pos_to_completion.get((x, y)) + if completion: + b.apply_completion(completion) + + return None + + def get_key_bindings(self) -> KeyBindings: + """ + Expose key bindings that handle the left/right arrow keys when the menu + is displayed. + """ + from prompt_toolkit.key_binding.key_bindings import KeyBindings + + kb = KeyBindings() + + @Condition + def filter() -> bool: + "Only handle key bindings if this menu is visible." + app = get_app() + complete_state = app.current_buffer.complete_state + + # There need to be completions, and one needs to be selected. + if complete_state is None or complete_state.complete_index is None: + return False + + # This menu needs to be visible. + return any(window.content == self for window in app.layout.visible_windows) + + def move(right: bool = False) -> None: + buff = get_app().current_buffer + complete_state = buff.complete_state + + if complete_state is not None and complete_state.complete_index is not None: + # Calculate new complete index. + new_index = complete_state.complete_index + if right: + new_index += self._rendered_rows + else: + new_index -= self._rendered_rows + + if 0 <= new_index < len(complete_state.completions): + buff.go_to_completion(new_index) + + # NOTE: the is_global is required because the completion menu will + # never be focussed. + + @kb.add("left", is_global=True, filter=filter) + def _left(event: E) -> None: + move() + + @kb.add("right", is_global=True, filter=filter) + def _right(event: E) -> None: + move(True) + + return kb + + +class MultiColumnCompletionsMenu(HSplit): + """ + Container that displays the completions in several columns. + When `show_meta` (a :class:`~prompt_toolkit.filters.Filter`) evaluates + to True, it shows the meta information at the bottom. + """ + + def __init__( + self, + min_rows: int = 3, + suggested_max_column_width: int = 30, + show_meta: FilterOrBool = True, + extra_filter: FilterOrBool = True, + z_index: int = 10**8, + ) -> None: + show_meta = to_filter(show_meta) + extra_filter = to_filter(extra_filter) + + # Display filter: show when there are completions but not at the point + # we are returning the input. + full_filter = extra_filter & has_completions & ~is_done + + @Condition + def any_completion_has_meta() -> bool: + complete_state = get_app().current_buffer.complete_state + return complete_state is not None and any( + c.display_meta for c in complete_state.completions + ) + + # Create child windows. + # NOTE: We don't set style='class:completion-menu' to the + # `MultiColumnCompletionMenuControl`, because this is used in a + # Float that is made transparent, and the size of the control + # doesn't always correspond exactly with the size of the + # generated content. + completions_window = ConditionalContainer( + content=Window( + content=MultiColumnCompletionMenuControl( + min_rows=min_rows, + suggested_max_column_width=suggested_max_column_width, + ), + width=Dimension(min=8), + height=Dimension(min=1), + ), + filter=full_filter, + ) + + meta_window = ConditionalContainer( + content=Window(content=_SelectedCompletionMetaControl()), + filter=full_filter & show_meta & any_completion_has_meta, + ) + + # Initialize split. + super().__init__([completions_window, meta_window], z_index=z_index) + + +class _SelectedCompletionMetaControl(UIControl): + """ + Control that shows the meta information of the selected completion. + """ + + def preferred_width(self, max_available_width: int) -> int | None: + """ + Report the width of the longest meta text as the preferred width of this control. + + It could be that we use less width, but this way, we're sure that the + layout doesn't change when we select another completion (E.g. that + completions are suddenly shown in more or fewer columns.) + """ + app = get_app() + if app.current_buffer.complete_state: + state = app.current_buffer.complete_state + + if len(state.completions) >= 30: + # When there are many completions, calling `get_cwidth` for + # every `display_meta_text` is too expensive. In this case, + # just return the max available width. There will be enough + # columns anyway so that the whole screen is filled with + # completions and `create_content` will then take up as much + # space as needed. + return max_available_width + + return 2 + max( + get_cwidth(c.display_meta_text) for c in state.completions[:100] + ) + else: + return 0 + + def preferred_height( + self, + width: int, + max_available_height: int, + wrap_lines: bool, + get_line_prefix: GetLinePrefixCallable | None, + ) -> int | None: + return 1 + + def create_content(self, width: int, height: int) -> UIContent: + fragments = self._get_text_fragments() + + def get_line(i: int) -> StyleAndTextTuples: + return fragments + + return UIContent(get_line=get_line, line_count=1 if fragments else 0) + + def _get_text_fragments(self) -> StyleAndTextTuples: + style = "class:completion-menu.multi-column-meta" + state = get_app().current_buffer.complete_state + + if ( + state + and state.current_completion + and state.current_completion.display_meta_text + ): + return to_formatted_text( + cast(StyleAndTextTuples, [("", " ")]) + + state.current_completion.display_meta + + [("", " ")], + style=style, + ) + + return [] diff --git a/src/prompt_toolkit/layout/mouse_handlers.py b/src/prompt_toolkit/layout/mouse_handlers.py new file mode 100644 index 0000000..56a4edd --- /dev/null +++ b/src/prompt_toolkit/layout/mouse_handlers.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import TYPE_CHECKING, Callable + +from prompt_toolkit.mouse_events import MouseEvent + +if TYPE_CHECKING: + from prompt_toolkit.key_binding.key_bindings import NotImplementedOrNone + +__all__ = [ + "MouseHandler", + "MouseHandlers", +] + + +MouseHandler = Callable[[MouseEvent], "NotImplementedOrNone"] + + +class MouseHandlers: + """ + Two dimensional raster of callbacks for mouse events. + """ + + def __init__(self) -> None: + def dummy_callback(mouse_event: MouseEvent) -> NotImplementedOrNone: + """ + :param mouse_event: `MouseEvent` instance. + """ + return NotImplemented + + # NOTE: Previously, the data structure was a dictionary mapping (x,y) + # to the handlers. This however would be more inefficient when copying + # over the mouse handlers of the visible region in the scrollable pane. + + # Map y (row) to x (column) to handlers. + self.mouse_handlers: defaultdict[ + int, defaultdict[int, MouseHandler] + ] = defaultdict(lambda: defaultdict(lambda: dummy_callback)) + + def set_mouse_handler_for_range( + self, + x_min: int, + x_max: int, + y_min: int, + y_max: int, + handler: Callable[[MouseEvent], NotImplementedOrNone], + ) -> None: + """ + Set mouse handler for a region. + """ + for y in range(y_min, y_max): + row = self.mouse_handlers[y] + + for x in range(x_min, x_max): + row[x] = handler diff --git a/src/prompt_toolkit/layout/processors.py b/src/prompt_toolkit/layout/processors.py new file mode 100644 index 0000000..b737611 --- /dev/null +++ b/src/prompt_toolkit/layout/processors.py @@ -0,0 +1,1013 @@ +""" +Processors are little transformation blocks that transform the fragments list +from a buffer before the BufferControl will render it to the screen. + +They can insert fragments before or after, or highlight fragments by replacing the +fragment types. +""" +from __future__ import annotations + +import re +from abc import ABCMeta, abstractmethod +from typing import TYPE_CHECKING, Callable, Hashable, cast + +from prompt_toolkit.application.current import get_app +from prompt_toolkit.cache import SimpleCache +from prompt_toolkit.document import Document +from prompt_toolkit.filters import FilterOrBool, to_filter, vi_insert_multiple_mode +from prompt_toolkit.formatted_text import ( + AnyFormattedText, + StyleAndTextTuples, + to_formatted_text, +) +from prompt_toolkit.formatted_text.utils import fragment_list_len, fragment_list_to_text +from prompt_toolkit.search import SearchDirection +from prompt_toolkit.utils import to_int, to_str + +from .utils import explode_text_fragments + +if TYPE_CHECKING: + from .controls import BufferControl, UIContent + +__all__ = [ + "Processor", + "TransformationInput", + "Transformation", + "DummyProcessor", + "HighlightSearchProcessor", + "HighlightIncrementalSearchProcessor", + "HighlightSelectionProcessor", + "PasswordProcessor", + "HighlightMatchingBracketProcessor", + "DisplayMultipleCursors", + "BeforeInput", + "ShowArg", + "AfterInput", + "AppendAutoSuggestion", + "ConditionalProcessor", + "ShowLeadingWhiteSpaceProcessor", + "ShowTrailingWhiteSpaceProcessor", + "TabsProcessor", + "ReverseSearchProcessor", + "DynamicProcessor", + "merge_processors", +] + + +class Processor(metaclass=ABCMeta): + """ + Manipulate the fragments for a given line in a + :class:`~prompt_toolkit.layout.controls.BufferControl`. + """ + + @abstractmethod + def apply_transformation( + self, transformation_input: TransformationInput + ) -> Transformation: + """ + Apply transformation. Returns a :class:`.Transformation` instance. + + :param transformation_input: :class:`.TransformationInput` object. + """ + return Transformation(transformation_input.fragments) + + +SourceToDisplay = Callable[[int], int] +DisplayToSource = Callable[[int], int] + + +class TransformationInput: + """ + :param buffer_control: :class:`.BufferControl` instance. + :param lineno: The number of the line to which we apply the processor. + :param source_to_display: A function that returns the position in the + `fragments` for any position in the source string. (This takes + previous processors into account.) + :param fragments: List of fragments that we can transform. (Received from the + previous processor.) + """ + + def __init__( + self, + buffer_control: BufferControl, + document: Document, + lineno: int, + source_to_display: SourceToDisplay, + fragments: StyleAndTextTuples, + width: int, + height: int, + ) -> None: + self.buffer_control = buffer_control + self.document = document + self.lineno = lineno + self.source_to_display = source_to_display + self.fragments = fragments + self.width = width + self.height = height + + def unpack( + self, + ) -> tuple[ + BufferControl, Document, int, SourceToDisplay, StyleAndTextTuples, int, int + ]: + return ( + self.buffer_control, + self.document, + self.lineno, + self.source_to_display, + self.fragments, + self.width, + self.height, + ) + + +class Transformation: + """ + Transformation result, as returned by :meth:`.Processor.apply_transformation`. + + Important: Always make sure that the length of `document.text` is equal to + the length of all the text in `fragments`! + + :param fragments: The transformed fragments. To be displayed, or to pass to + the next processor. + :param source_to_display: Cursor position transformation from original + string to transformed string. + :param display_to_source: Cursor position transformed from source string to + original string. + """ + + def __init__( + self, + fragments: StyleAndTextTuples, + source_to_display: SourceToDisplay | None = None, + display_to_source: DisplayToSource | None = None, + ) -> None: + self.fragments = fragments + self.source_to_display = source_to_display or (lambda i: i) + self.display_to_source = display_to_source or (lambda i: i) + + +class DummyProcessor(Processor): + """ + A `Processor` that doesn't do anything. + """ + + def apply_transformation( + self, transformation_input: TransformationInput + ) -> Transformation: + return Transformation(transformation_input.fragments) + + +class HighlightSearchProcessor(Processor): + """ + Processor that highlights search matches in the document. + Note that this doesn't support multiline search matches yet. + + The style classes 'search' and 'search.current' will be applied to the + content. + """ + + _classname = "search" + _classname_current = "search.current" + + def _get_search_text(self, buffer_control: BufferControl) -> str: + """ + The text we are searching for. + """ + return buffer_control.search_state.text + + def apply_transformation( + self, transformation_input: TransformationInput + ) -> Transformation: + ( + buffer_control, + document, + lineno, + source_to_display, + fragments, + _, + _, + ) = transformation_input.unpack() + + search_text = self._get_search_text(buffer_control) + searchmatch_fragment = f" class:{self._classname} " + searchmatch_current_fragment = f" class:{self._classname_current} " + + if search_text and not get_app().is_done: + # For each search match, replace the style string. + line_text = fragment_list_to_text(fragments) + fragments = explode_text_fragments(fragments) + + if buffer_control.search_state.ignore_case(): + flags = re.IGNORECASE + else: + flags = re.RegexFlag(0) + + # Get cursor column. + cursor_column: int | None + if document.cursor_position_row == lineno: + cursor_column = source_to_display(document.cursor_position_col) + else: + cursor_column = None + + for match in re.finditer(re.escape(search_text), line_text, flags=flags): + if cursor_column is not None: + on_cursor = match.start() <= cursor_column < match.end() + else: + on_cursor = False + + for i in range(match.start(), match.end()): + old_fragment, text, *_ = fragments[i] + if on_cursor: + fragments[i] = ( + old_fragment + searchmatch_current_fragment, + fragments[i][1], + ) + else: + fragments[i] = ( + old_fragment + searchmatch_fragment, + fragments[i][1], + ) + + return Transformation(fragments) + + +class HighlightIncrementalSearchProcessor(HighlightSearchProcessor): + """ + Highlight the search terms that are used for highlighting the incremental + search. The style class 'incsearch' will be applied to the content. + + Important: this requires the `preview_search=True` flag to be set for the + `BufferControl`. Otherwise, the cursor position won't be set to the search + match while searching, and nothing happens. + """ + + _classname = "incsearch" + _classname_current = "incsearch.current" + + def _get_search_text(self, buffer_control: BufferControl) -> str: + """ + The text we are searching for. + """ + # When the search buffer has focus, take that text. + search_buffer = buffer_control.search_buffer + if search_buffer is not None and search_buffer.text: + return search_buffer.text + return "" + + +class HighlightSelectionProcessor(Processor): + """ + Processor that highlights the selection in the document. + """ + + def apply_transformation( + self, transformation_input: TransformationInput + ) -> Transformation: + ( + buffer_control, + document, + lineno, + source_to_display, + fragments, + _, + _, + ) = transformation_input.unpack() + + selected_fragment = " class:selected " + + # In case of selection, highlight all matches. + selection_at_line = document.selection_range_at_line(lineno) + + if selection_at_line: + from_, to = selection_at_line + from_ = source_to_display(from_) + to = source_to_display(to) + + fragments = explode_text_fragments(fragments) + + if from_ == 0 and to == 0 and len(fragments) == 0: + # When this is an empty line, insert a space in order to + # visualize the selection. + return Transformation([(selected_fragment, " ")]) + else: + for i in range(from_, to): + if i < len(fragments): + old_fragment, old_text, *_ = fragments[i] + fragments[i] = (old_fragment + selected_fragment, old_text) + elif i == len(fragments): + fragments.append((selected_fragment, " ")) + + return Transformation(fragments) + + +class PasswordProcessor(Processor): + """ + Processor that masks the input. (For passwords.) + + :param char: (string) Character to be used. "*" by default. + """ + + def __init__(self, char: str = "*") -> None: + self.char = char + + def apply_transformation(self, ti: TransformationInput) -> Transformation: + fragments: StyleAndTextTuples = cast( + StyleAndTextTuples, + [ + (style, self.char * len(text), *handler) + for style, text, *handler in ti.fragments + ], + ) + + return Transformation(fragments) + + +class HighlightMatchingBracketProcessor(Processor): + """ + When the cursor is on or right after a bracket, it highlights the matching + bracket. + + :param max_cursor_distance: Only highlight matching brackets when the + cursor is within this distance. (From inside a `Processor`, we can't + know which lines will be visible on the screen. But we also don't want + to scan the whole document for matching brackets on each key press, so + we limit to this value.) + """ + + _closing_braces = "])}>" + + def __init__( + self, chars: str = "[](){}<>", max_cursor_distance: int = 1000 + ) -> None: + self.chars = chars + self.max_cursor_distance = max_cursor_distance + + self._positions_cache: SimpleCache[ + Hashable, list[tuple[int, int]] + ] = SimpleCache(maxsize=8) + + def _get_positions_to_highlight(self, document: Document) -> list[tuple[int, int]]: + """ + Return a list of (row, col) tuples that need to be highlighted. + """ + pos: int | None + + # Try for the character under the cursor. + if document.current_char and document.current_char in self.chars: + pos = document.find_matching_bracket_position( + start_pos=document.cursor_position - self.max_cursor_distance, + end_pos=document.cursor_position + self.max_cursor_distance, + ) + + # Try for the character before the cursor. + elif ( + document.char_before_cursor + and document.char_before_cursor in self._closing_braces + and document.char_before_cursor in self.chars + ): + document = Document(document.text, document.cursor_position - 1) + + pos = document.find_matching_bracket_position( + start_pos=document.cursor_position - self.max_cursor_distance, + end_pos=document.cursor_position + self.max_cursor_distance, + ) + else: + pos = None + + # Return a list of (row, col) tuples that need to be highlighted. + if pos: + pos += document.cursor_position # pos is relative. + row, col = document.translate_index_to_position(pos) + return [ + (row, col), + (document.cursor_position_row, document.cursor_position_col), + ] + else: + return [] + + def apply_transformation( + self, transformation_input: TransformationInput + ) -> Transformation: + ( + buffer_control, + document, + lineno, + source_to_display, + fragments, + _, + _, + ) = transformation_input.unpack() + + # When the application is in the 'done' state, don't highlight. + if get_app().is_done: + return Transformation(fragments) + + # Get the highlight positions. + key = (get_app().render_counter, document.text, document.cursor_position) + positions = self._positions_cache.get( + key, lambda: self._get_positions_to_highlight(document) + ) + + # Apply if positions were found at this line. + if positions: + for row, col in positions: + if row == lineno: + col = source_to_display(col) + fragments = explode_text_fragments(fragments) + style, text, *_ = fragments[col] + + if col == document.cursor_position_col: + style += " class:matching-bracket.cursor " + else: + style += " class:matching-bracket.other " + + fragments[col] = (style, text) + + return Transformation(fragments) + + +class DisplayMultipleCursors(Processor): + """ + When we're in Vi block insert mode, display all the cursors. + """ + + def apply_transformation( + self, transformation_input: TransformationInput + ) -> Transformation: + ( + buffer_control, + document, + lineno, + source_to_display, + fragments, + _, + _, + ) = transformation_input.unpack() + + buff = buffer_control.buffer + + if vi_insert_multiple_mode(): + cursor_positions = buff.multiple_cursor_positions + fragments = explode_text_fragments(fragments) + + # If any cursor appears on the current line, highlight that. + start_pos = document.translate_row_col_to_index(lineno, 0) + end_pos = start_pos + len(document.lines[lineno]) + + fragment_suffix = " class:multiple-cursors" + + for p in cursor_positions: + if start_pos <= p <= end_pos: + column = source_to_display(p - start_pos) + + # Replace fragment. + try: + style, text, *_ = fragments[column] + except IndexError: + # Cursor needs to be displayed after the current text. + fragments.append((fragment_suffix, " ")) + else: + style += fragment_suffix + fragments[column] = (style, text) + + return Transformation(fragments) + else: + return Transformation(fragments) + + +class BeforeInput(Processor): + """ + Insert text before the input. + + :param text: This can be either plain text or formatted text + (or a callable that returns any of those). + :param style: style to be applied to this prompt/prefix. + """ + + def __init__(self, text: AnyFormattedText, style: str = "") -> None: + self.text = text + self.style = style + + def apply_transformation(self, ti: TransformationInput) -> Transformation: + source_to_display: SourceToDisplay | None + display_to_source: DisplayToSource | None + + if ti.lineno == 0: + # Get fragments. + fragments_before = to_formatted_text(self.text, self.style) + fragments = fragments_before + ti.fragments + + shift_position = fragment_list_len(fragments_before) + source_to_display = lambda i: i + shift_position + display_to_source = lambda i: i - shift_position + else: + fragments = ti.fragments + source_to_display = None + display_to_source = None + + return Transformation( + fragments, + source_to_display=source_to_display, + display_to_source=display_to_source, + ) + + def __repr__(self) -> str: + return f"BeforeInput({self.text!r}, {self.style!r})" + + +class ShowArg(BeforeInput): + """ + Display the 'arg' in front of the input. + + This was used by the `PromptSession`, but now it uses the + `Window.get_line_prefix` function instead. + """ + + def __init__(self) -> None: + super().__init__(self._get_text_fragments) + + def _get_text_fragments(self) -> StyleAndTextTuples: + app = get_app() + if app.key_processor.arg is None: + return [] + else: + arg = app.key_processor.arg + + return [ + ("class:prompt.arg", "(arg: "), + ("class:prompt.arg.text", str(arg)), + ("class:prompt.arg", ") "), + ] + + def __repr__(self) -> str: + return "ShowArg()" + + +class AfterInput(Processor): + """ + Insert text after the input. + + :param text: This can be either plain text or formatted text + (or a callable that returns any of those). + :param style: style to be applied to this prompt/prefix. + """ + + def __init__(self, text: AnyFormattedText, style: str = "") -> None: + self.text = text + self.style = style + + def apply_transformation(self, ti: TransformationInput) -> Transformation: + # Insert fragments after the last line. + if ti.lineno == ti.document.line_count - 1: + # Get fragments. + fragments_after = to_formatted_text(self.text, self.style) + return Transformation(fragments=ti.fragments + fragments_after) + else: + return Transformation(fragments=ti.fragments) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.text!r}, style={self.style!r})" + + +class AppendAutoSuggestion(Processor): + """ + Append the auto suggestion to the input. + (The user can then press the right arrow the insert the suggestion.) + """ + + def __init__(self, style: str = "class:auto-suggestion") -> None: + self.style = style + + def apply_transformation(self, ti: TransformationInput) -> Transformation: + # Insert fragments after the last line. + if ti.lineno == ti.document.line_count - 1: + buffer = ti.buffer_control.buffer + + if buffer.suggestion and ti.document.is_cursor_at_the_end: + suggestion = buffer.suggestion.text + else: + suggestion = "" + + return Transformation(fragments=ti.fragments + [(self.style, suggestion)]) + else: + return Transformation(fragments=ti.fragments) + + +class ShowLeadingWhiteSpaceProcessor(Processor): + """ + Make leading whitespace visible. + + :param get_char: Callable that returns one character. + """ + + def __init__( + self, + get_char: Callable[[], str] | None = None, + style: str = "class:leading-whitespace", + ) -> None: + def default_get_char() -> str: + if "\xb7".encode(get_app().output.encoding(), "replace") == b"?": + return "." + else: + return "\xb7" + + self.style = style + self.get_char = get_char or default_get_char + + def apply_transformation(self, ti: TransformationInput) -> Transformation: + fragments = ti.fragments + + # Walk through all te fragments. + if fragments and fragment_list_to_text(fragments).startswith(" "): + t = (self.style, self.get_char()) + fragments = explode_text_fragments(fragments) + + for i in range(len(fragments)): + if fragments[i][1] == " ": + fragments[i] = t + else: + break + + return Transformation(fragments) + + +class ShowTrailingWhiteSpaceProcessor(Processor): + """ + Make trailing whitespace visible. + + :param get_char: Callable that returns one character. + """ + + def __init__( + self, + get_char: Callable[[], str] | None = None, + style: str = "class:training-whitespace", + ) -> None: + def default_get_char() -> str: + if "\xb7".encode(get_app().output.encoding(), "replace") == b"?": + return "." + else: + return "\xb7" + + self.style = style + self.get_char = get_char or default_get_char + + def apply_transformation(self, ti: TransformationInput) -> Transformation: + fragments = ti.fragments + + if fragments and fragments[-1][1].endswith(" "): + t = (self.style, self.get_char()) + fragments = explode_text_fragments(fragments) + + # Walk backwards through all te fragments and replace whitespace. + for i in range(len(fragments) - 1, -1, -1): + char = fragments[i][1] + if char == " ": + fragments[i] = t + else: + break + + return Transformation(fragments) + + +class TabsProcessor(Processor): + """ + Render tabs as spaces (instead of ^I) or make them visible (for instance, + by replacing them with dots.) + + :param tabstop: Horizontal space taken by a tab. (`int` or callable that + returns an `int`). + :param char1: Character or callable that returns a character (text of + length one). This one is used for the first space taken by the tab. + :param char2: Like `char1`, but for the rest of the space. + """ + + def __init__( + self, + tabstop: int | Callable[[], int] = 4, + char1: str | Callable[[], str] = "|", + char2: str | Callable[[], str] = "\u2508", + style: str = "class:tab", + ) -> None: + self.char1 = char1 + self.char2 = char2 + self.tabstop = tabstop + self.style = style + + def apply_transformation(self, ti: TransformationInput) -> Transformation: + tabstop = to_int(self.tabstop) + style = self.style + + # Create separator for tabs. + separator1 = to_str(self.char1) + separator2 = to_str(self.char2) + + # Transform fragments. + fragments = explode_text_fragments(ti.fragments) + + position_mappings = {} + result_fragments: StyleAndTextTuples = [] + pos = 0 + + for i, fragment_and_text in enumerate(fragments): + position_mappings[i] = pos + + if fragment_and_text[1] == "\t": + # Calculate how many characters we have to insert. + count = tabstop - (pos % tabstop) + if count == 0: + count = tabstop + + # Insert tab. + result_fragments.append((style, separator1)) + result_fragments.append((style, separator2 * (count - 1))) + pos += count + else: + result_fragments.append(fragment_and_text) + pos += 1 + + position_mappings[len(fragments)] = pos + # Add `pos+1` to mapping, because the cursor can be right after the + # line as well. + position_mappings[len(fragments) + 1] = pos + 1 + + def source_to_display(from_position: int) -> int: + "Maps original cursor position to the new one." + return position_mappings[from_position] + + def display_to_source(display_pos: int) -> int: + "Maps display cursor position to the original one." + position_mappings_reversed = {v: k for k, v in position_mappings.items()} + + while display_pos >= 0: + try: + return position_mappings_reversed[display_pos] + except KeyError: + display_pos -= 1 + return 0 + + return Transformation( + result_fragments, + source_to_display=source_to_display, + display_to_source=display_to_source, + ) + + +class ReverseSearchProcessor(Processor): + """ + Process to display the "(reverse-i-search)`...`:..." stuff around + the search buffer. + + Note: This processor is meant to be applied to the BufferControl that + contains the search buffer, it's not meant for the original input. + """ + + _excluded_input_processors: list[type[Processor]] = [ + HighlightSearchProcessor, + HighlightSelectionProcessor, + BeforeInput, + AfterInput, + ] + + def _get_main_buffer(self, buffer_control: BufferControl) -> BufferControl | None: + from prompt_toolkit.layout.controls import BufferControl + + prev_control = get_app().layout.search_target_buffer_control + if ( + isinstance(prev_control, BufferControl) + and prev_control.search_buffer_control == buffer_control + ): + return prev_control + return None + + def _content( + self, main_control: BufferControl, ti: TransformationInput + ) -> UIContent: + from prompt_toolkit.layout.controls import BufferControl + + # Emulate the BufferControl through which we are searching. + # For this we filter out some of the input processors. + excluded_processors = tuple(self._excluded_input_processors) + + def filter_processor(item: Processor) -> Processor | None: + """Filter processors from the main control that we want to disable + here. This returns either an accepted processor or None.""" + # For a `_MergedProcessor`, check each individual processor, recursively. + if isinstance(item, _MergedProcessor): + accepted_processors = [filter_processor(p) for p in item.processors] + return merge_processors( + [p for p in accepted_processors if p is not None] + ) + + # For a `ConditionalProcessor`, check the body. + elif isinstance(item, ConditionalProcessor): + p = filter_processor(item.processor) + if p: + return ConditionalProcessor(p, item.filter) + + # Otherwise, check the processor itself. + else: + if not isinstance(item, excluded_processors): + return item + + return None + + filtered_processor = filter_processor( + merge_processors(main_control.input_processors or []) + ) + highlight_processor = HighlightIncrementalSearchProcessor() + + if filtered_processor: + new_processors = [filtered_processor, highlight_processor] + else: + new_processors = [highlight_processor] + + from .controls import SearchBufferControl + + assert isinstance(ti.buffer_control, SearchBufferControl) + + buffer_control = BufferControl( + buffer=main_control.buffer, + input_processors=new_processors, + include_default_input_processors=False, + lexer=main_control.lexer, + preview_search=True, + search_buffer_control=ti.buffer_control, + ) + + return buffer_control.create_content(ti.width, ti.height, preview_search=True) + + 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." + + source_to_display: SourceToDisplay | None + display_to_source: DisplayToSource | None + + main_control = self._get_main_buffer(ti.buffer_control) + + if ti.lineno == 0 and main_control: + content = self._content(main_control, ti) + + # Get the line from the original document for this search. + line_fragments = content.get_line(content.cursor_position.y) + + if main_control.search_state.direction == SearchDirection.FORWARD: + direction_text = "i-search" + else: + direction_text = "reverse-i-search" + + fragments_before: StyleAndTextTuples = [ + ("class:prompt.search", "("), + ("class:prompt.search", direction_text), + ("class:prompt.search", ")`"), + ] + + fragments = ( + fragments_before + + [ + ("class:prompt.search.text", fragment_list_to_text(ti.fragments)), + ("", "': "), + ] + + line_fragments + ) + + shift_position = fragment_list_len(fragments_before) + source_to_display = lambda i: i + shift_position + display_to_source = lambda i: i - shift_position + else: + source_to_display = None + display_to_source = None + fragments = ti.fragments + + return Transformation( + fragments, + source_to_display=source_to_display, + display_to_source=display_to_source, + ) + + +class ConditionalProcessor(Processor): + """ + Processor that applies another processor, according to a certain condition. + Example:: + + # Create a function that returns whether or not the processor should + # currently be applied. + def highlight_enabled(): + return true_or_false + + # Wrapped it in a `ConditionalProcessor` for usage in a `BufferControl`. + BufferControl(input_processors=[ + ConditionalProcessor(HighlightSearchProcessor(), + Condition(highlight_enabled))]) + + :param processor: :class:`.Processor` instance. + :param filter: :class:`~prompt_toolkit.filters.Filter` instance. + """ + + def __init__(self, processor: Processor, filter: FilterOrBool) -> None: + self.processor = processor + self.filter = to_filter(filter) + + def apply_transformation( + self, transformation_input: TransformationInput + ) -> Transformation: + # Run processor when enabled. + if self.filter(): + return self.processor.apply_transformation(transformation_input) + else: + return Transformation(transformation_input.fragments) + + def __repr__(self) -> str: + return "{}(processor={!r}, filter={!r})".format( + self.__class__.__name__, + self.processor, + self.filter, + ) + + +class DynamicProcessor(Processor): + """ + Processor class that dynamically returns any Processor. + + :param get_processor: Callable that returns a :class:`.Processor` instance. + """ + + def __init__(self, get_processor: Callable[[], Processor | None]) -> None: + self.get_processor = get_processor + + def apply_transformation(self, ti: TransformationInput) -> Transformation: + processor = self.get_processor() or DummyProcessor() + return processor.apply_transformation(ti) + + +def merge_processors(processors: list[Processor]) -> Processor: + """ + Merge multiple `Processor` objects into one. + """ + if len(processors) == 0: + return DummyProcessor() + + if len(processors) == 1: + return processors[0] # Nothing to merge. + + return _MergedProcessor(processors) + + +class _MergedProcessor(Processor): + """ + Processor that groups multiple other `Processor` objects, but exposes an + API as if it is one `Processor`. + """ + + def __init__(self, processors: list[Processor]): + self.processors = processors + + def apply_transformation(self, ti: TransformationInput) -> Transformation: + source_to_display_functions = [ti.source_to_display] + display_to_source_functions = [] + fragments = ti.fragments + + def source_to_display(i: int) -> int: + """Translate x position from the buffer to the x position in the + processor fragments list.""" + for f in source_to_display_functions: + i = f(i) + return i + + for p in self.processors: + transformation = p.apply_transformation( + TransformationInput( + ti.buffer_control, + ti.document, + ti.lineno, + source_to_display, + fragments, + ti.width, + ti.height, + ) + ) + fragments = transformation.fragments + display_to_source_functions.append(transformation.display_to_source) + source_to_display_functions.append(transformation.source_to_display) + + def display_to_source(i: int) -> int: + for f in reversed(display_to_source_functions): + i = f(i) + return i + + # In the case of a nested _MergedProcessor, each processor wants to + # receive a 'source_to_display' function (as part of the + # TransformationInput) that has everything in the chain before + # included, because it can be called as part of the + # `apply_transformation` function. However, this first + # `source_to_display` should not be part of the output that we are + # returning. (This is the most consistent with `display_to_source`.) + del source_to_display_functions[:1] + + return Transformation(fragments, source_to_display, display_to_source) diff --git a/src/prompt_toolkit/layout/screen.py b/src/prompt_toolkit/layout/screen.py new file mode 100644 index 0000000..49aebbd --- /dev/null +++ b/src/prompt_toolkit/layout/screen.py @@ -0,0 +1,329 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import TYPE_CHECKING, Callable + +from prompt_toolkit.cache import FastDictCache +from prompt_toolkit.data_structures import Point +from prompt_toolkit.utils import get_cwidth + +if TYPE_CHECKING: + from .containers import Window + + +__all__ = [ + "Screen", + "Char", +] + + +class Char: + """ + Represent a single character in a :class:`.Screen`. + + This should be considered immutable. + + :param char: A single character (can be a double-width character). + :param style: A style string. (Can contain classnames.) + """ + + __slots__ = ("char", "style", "width") + + # If we end up having one of these special control sequences in the input string, + # we should display them as follows: + # Usually this happens after a "quoted insert". + display_mappings: dict[str, str] = { + "\x00": "^@", # Control space + "\x01": "^A", + "\x02": "^B", + "\x03": "^C", + "\x04": "^D", + "\x05": "^E", + "\x06": "^F", + "\x07": "^G", + "\x08": "^H", + "\x09": "^I", + "\x0a": "^J", + "\x0b": "^K", + "\x0c": "^L", + "\x0d": "^M", + "\x0e": "^N", + "\x0f": "^O", + "\x10": "^P", + "\x11": "^Q", + "\x12": "^R", + "\x13": "^S", + "\x14": "^T", + "\x15": "^U", + "\x16": "^V", + "\x17": "^W", + "\x18": "^X", + "\x19": "^Y", + "\x1a": "^Z", + "\x1b": "^[", # Escape + "\x1c": "^\\", + "\x1d": "^]", + "\x1e": "^^", + "\x1f": "^_", + "\x7f": "^?", # ASCII Delete (backspace). + # Special characters. All visualized like Vim does. + "\x80": "<80>", + "\x81": "<81>", + "\x82": "<82>", + "\x83": "<83>", + "\x84": "<84>", + "\x85": "<85>", + "\x86": "<86>", + "\x87": "<87>", + "\x88": "<88>", + "\x89": "<89>", + "\x8a": "<8a>", + "\x8b": "<8b>", + "\x8c": "<8c>", + "\x8d": "<8d>", + "\x8e": "<8e>", + "\x8f": "<8f>", + "\x90": "<90>", + "\x91": "<91>", + "\x92": "<92>", + "\x93": "<93>", + "\x94": "<94>", + "\x95": "<95>", + "\x96": "<96>", + "\x97": "<97>", + "\x98": "<98>", + "\x99": "<99>", + "\x9a": "<9a>", + "\x9b": "<9b>", + "\x9c": "<9c>", + "\x9d": "<9d>", + "\x9e": "<9e>", + "\x9f": "<9f>", + # For the non-breaking space: visualize like Emacs does by default. + # (Print a space, but attach the 'nbsp' class that applies the + # underline style.) + "\xa0": " ", + } + + def __init__(self, char: str = " ", style: str = "") -> None: + # If this character has to be displayed otherwise, take that one. + if char in self.display_mappings: + if char == "\xa0": + style += " class:nbsp " # Will be underlined. + else: + style += " class:control-character " + + char = self.display_mappings[char] + + self.char = char + self.style = style + + # Calculate width. (We always need this, so better to store it directly + # as a member for performance.) + self.width = get_cwidth(char) + + # In theory, `other` can be any type of object, but because of performance + # we don't want to do an `isinstance` check every time. We assume "other" + # is always a "Char". + def _equal(self, other: Char) -> bool: + return self.char == other.char and self.style == other.style + + def _not_equal(self, other: Char) -> bool: + # Not equal: We don't do `not char.__eq__` here, because of the + # performance of calling yet another function. + return self.char != other.char or self.style != other.style + + if not TYPE_CHECKING: + __eq__ = _equal + __ne__ = _not_equal + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.char!r}, {self.style!r})" + + +_CHAR_CACHE: FastDictCache[tuple[str, str], Char] = FastDictCache( + Char, size=1000 * 1000 +) +Transparent = "[transparent]" + + +class Screen: + """ + Two dimensional buffer of :class:`.Char` instances. + """ + + def __init__( + self, + default_char: Char | None = None, + initial_width: int = 0, + initial_height: int = 0, + ) -> None: + if default_char is None: + default_char2 = _CHAR_CACHE[" ", Transparent] + else: + default_char2 = default_char + + self.data_buffer: defaultdict[int, defaultdict[int, Char]] = defaultdict( + lambda: defaultdict(lambda: default_char2) + ) + + #: Escape sequences to be injected. + self.zero_width_escapes: defaultdict[int, defaultdict[int, str]] = defaultdict( + lambda: defaultdict(lambda: "") + ) + + #: Position of the cursor. + self.cursor_positions: dict[ + Window, Point + ] = {} # Map `Window` objects to `Point` objects. + + #: Visibility of the cursor. + self.show_cursor = True + + #: (Optional) Where to position the menu. E.g. at the start of a completion. + #: (We can't use the cursor position, because we don't want the + #: completion menu to change its position when we browse through all the + #: completions.) + self.menu_positions: dict[ + Window, Point + ] = {} # Map `Window` objects to `Point` objects. + + #: Currently used width/height of the screen. This will increase when + #: data is written to the screen. + self.width = initial_width or 0 + self.height = initial_height or 0 + + # Windows that have been drawn. (Each `Window` class will add itself to + # this list.) + self.visible_windows_to_write_positions: dict[Window, WritePosition] = {} + + # List of (z_index, draw_func) + self._draw_float_functions: list[tuple[int, Callable[[], None]]] = [] + + @property + def visible_windows(self) -> list[Window]: + return list(self.visible_windows_to_write_positions.keys()) + + def set_cursor_position(self, window: Window, position: Point) -> None: + """ + Set the cursor position for a given window. + """ + self.cursor_positions[window] = position + + def set_menu_position(self, window: Window, position: Point) -> None: + """ + Set the cursor position for a given window. + """ + self.menu_positions[window] = position + + def get_cursor_position(self, window: Window) -> Point: + """ + Get the cursor position for a given window. + Returns a `Point`. + """ + try: + return self.cursor_positions[window] + except KeyError: + return Point(x=0, y=0) + + def get_menu_position(self, window: Window) -> Point: + """ + Get the menu position for a given window. + (This falls back to the cursor position if no menu position was set.) + """ + try: + return self.menu_positions[window] + except KeyError: + try: + return self.cursor_positions[window] + except KeyError: + return Point(x=0, y=0) + + def draw_with_z_index(self, z_index: int, draw_func: Callable[[], None]) -> None: + """ + Add a draw-function for a `Window` which has a >= 0 z_index. + This will be postponed until `draw_all_floats` is called. + """ + self._draw_float_functions.append((z_index, draw_func)) + + def draw_all_floats(self) -> None: + """ + Draw all float functions in order of z-index. + """ + # We keep looping because some draw functions could add new functions + # to this list. See `FloatContainer`. + while self._draw_float_functions: + # Sort the floats that we have so far by z_index. + functions = sorted(self._draw_float_functions, key=lambda item: item[0]) + + # Draw only one at a time, then sort everything again. Now floats + # might have been added. + self._draw_float_functions = functions[1:] + functions[0][1]() + + def append_style_to_content(self, style_str: str) -> None: + """ + For all the characters in the screen. + Set the style string to the given `style_str`. + """ + b = self.data_buffer + char_cache = _CHAR_CACHE + + append_style = " " + style_str + + for y, row in b.items(): + for x, char in row.items(): + row[x] = char_cache[char.char, char.style + append_style] + + def fill_area( + self, write_position: WritePosition, style: str = "", after: bool = False + ) -> None: + """ + Fill the content of this area, using the given `style`. + The style is prepended before whatever was here before. + """ + if not style.strip(): + return + + xmin = write_position.xpos + xmax = write_position.xpos + write_position.width + char_cache = _CHAR_CACHE + data_buffer = self.data_buffer + + if after: + append_style = " " + style + prepend_style = "" + else: + append_style = "" + prepend_style = style + " " + + for y in range( + write_position.ypos, write_position.ypos + write_position.height + ): + row = data_buffer[y] + for x in range(xmin, xmax): + cell = row[x] + row[x] = char_cache[ + cell.char, prepend_style + cell.style + append_style + ] + + +class WritePosition: + def __init__(self, xpos: int, ypos: int, width: int, height: int) -> None: + assert height >= 0 + assert width >= 0 + # xpos and ypos can be negative. (A float can be partially visible.) + + self.xpos = xpos + self.ypos = ypos + self.width = width + self.height = height + + def __repr__(self) -> str: + return "{}(x={!r}, y={!r}, width={!r}, height={!r})".format( + self.__class__.__name__, + self.xpos, + self.ypos, + self.width, + self.height, + ) diff --git a/src/prompt_toolkit/layout/scrollable_pane.py b/src/prompt_toolkit/layout/scrollable_pane.py new file mode 100644 index 0000000..e38fd76 --- /dev/null +++ b/src/prompt_toolkit/layout/scrollable_pane.py @@ -0,0 +1,494 @@ +from __future__ import annotations + +from prompt_toolkit.data_structures import Point +from prompt_toolkit.filters import FilterOrBool, to_filter +from prompt_toolkit.key_binding import KeyBindingsBase +from prompt_toolkit.mouse_events import MouseEvent + +from .containers import Container, ScrollOffsets +from .dimension import AnyDimension, Dimension, sum_layout_dimensions, to_dimension +from .mouse_handlers import MouseHandler, MouseHandlers +from .screen import Char, Screen, WritePosition + +__all__ = ["ScrollablePane"] + +# Never go beyond this height, because performance will degrade. +MAX_AVAILABLE_HEIGHT = 10_000 + + +class ScrollablePane(Container): + """ + Container widget that exposes a larger virtual screen to its content and + displays it in a vertical scrollbale region. + + Typically this is wrapped in a large `HSplit` container. Make sure in that + case to not specify a `height` dimension of the `HSplit`, so that it will + scale according to the content. + + .. note:: + + If you want to display a completion menu for widgets in this + `ScrollablePane`, then it's still a good practice to use a + `FloatContainer` with a `CompletionsMenu` in a `Float` at the top-level + of the layout hierarchy, rather then nesting a `FloatContainer` in this + `ScrollablePane`. (Otherwise, it's possible that the completion menu + is clipped.) + + :param content: The content container. + :param scrolloffset: Try to keep the cursor within this distance from the + top/bottom (left/right offset is not used). + :param keep_cursor_visible: When `True`, automatically scroll the pane so + that the cursor (of the focused window) is always visible. + :param keep_focused_window_visible: When `True`, automatically scroll the + pane so that the focused window is visible, or as much visible as + possible if it doesn't completely fit the screen. + :param max_available_height: Always constraint the height to this amount + for performance reasons. + :param width: When given, use this width instead of looking at the children. + :param height: When given, use this height instead of looking at the children. + :param show_scrollbar: When `True` display a scrollbar on the right. + """ + + def __init__( + self, + content: Container, + scroll_offsets: ScrollOffsets | None = None, + keep_cursor_visible: FilterOrBool = True, + keep_focused_window_visible: FilterOrBool = True, + max_available_height: int = MAX_AVAILABLE_HEIGHT, + width: AnyDimension = None, + height: AnyDimension = None, + show_scrollbar: FilterOrBool = True, + display_arrows: FilterOrBool = True, + up_arrow_symbol: str = "^", + down_arrow_symbol: str = "v", + ) -> None: + self.content = content + self.scroll_offsets = scroll_offsets or ScrollOffsets(top=1, bottom=1) + self.keep_cursor_visible = to_filter(keep_cursor_visible) + self.keep_focused_window_visible = to_filter(keep_focused_window_visible) + self.max_available_height = max_available_height + self.width = width + self.height = height + self.show_scrollbar = to_filter(show_scrollbar) + self.display_arrows = to_filter(display_arrows) + self.up_arrow_symbol = up_arrow_symbol + self.down_arrow_symbol = down_arrow_symbol + + self.vertical_scroll = 0 + + def __repr__(self) -> str: + return f"ScrollablePane({self.content!r})" + + def reset(self) -> None: + self.content.reset() + + def preferred_width(self, max_available_width: int) -> Dimension: + if self.width is not None: + return to_dimension(self.width) + + # We're only scrolling vertical. So the preferred width is equal to + # that of the content. + content_width = self.content.preferred_width(max_available_width) + + # If a scrollbar needs to be displayed, add +1 to the content width. + if self.show_scrollbar(): + return sum_layout_dimensions([Dimension.exact(1), content_width]) + + return content_width + + def preferred_height(self, width: int, max_available_height: int) -> Dimension: + if self.height is not None: + return to_dimension(self.height) + + # Prefer a height large enough so that it fits all the content. If not, + # we'll make the pane scrollable. + if self.show_scrollbar(): + # If `show_scrollbar` is set. Always reserve space for the scrollbar. + width -= 1 + + dimension = self.content.preferred_height(width, self.max_available_height) + + # Only take 'preferred' into account. Min/max can be anything. + return Dimension(min=0, preferred=dimension.preferred) + + def write_to_screen( + self, + screen: Screen, + mouse_handlers: MouseHandlers, + write_position: WritePosition, + parent_style: str, + erase_bg: bool, + z_index: int | None, + ) -> None: + """ + Render scrollable pane content. + + This works by rendering on an off-screen canvas, and copying over the + visible region. + """ + show_scrollbar = self.show_scrollbar() + + if show_scrollbar: + virtual_width = write_position.width - 1 + else: + virtual_width = write_position.width + + # Compute preferred height again. + virtual_height = self.content.preferred_height( + virtual_width, self.max_available_height + ).preferred + + # Ensure virtual height is at least the available height. + virtual_height = max(virtual_height, write_position.height) + virtual_height = min(virtual_height, self.max_available_height) + + # First, write the content to a virtual screen, then copy over the + # visible part to the real screen. + temp_screen = Screen(default_char=Char(char=" ", style=parent_style)) + temp_screen.show_cursor = screen.show_cursor + temp_write_position = WritePosition( + xpos=0, ypos=0, width=virtual_width, height=virtual_height + ) + + temp_mouse_handlers = MouseHandlers() + + self.content.write_to_screen( + temp_screen, + temp_mouse_handlers, + temp_write_position, + parent_style, + erase_bg, + z_index, + ) + temp_screen.draw_all_floats() + + # If anything in the virtual screen is focused, move vertical scroll to + from prompt_toolkit.application import get_app + + focused_window = get_app().layout.current_window + + try: + visible_win_write_pos = temp_screen.visible_windows_to_write_positions[ + focused_window + ] + except KeyError: + pass # No window focused here. Don't scroll. + else: + # Make sure this window is visible. + self._make_window_visible( + write_position.height, + virtual_height, + visible_win_write_pos, + temp_screen.cursor_positions.get(focused_window), + ) + + # Copy over virtual screen and zero width escapes to real screen. + self._copy_over_screen(screen, temp_screen, write_position, virtual_width) + + # Copy over mouse handlers. + self._copy_over_mouse_handlers( + mouse_handlers, temp_mouse_handlers, write_position, virtual_width + ) + + # Set screen.width/height. + ypos = write_position.ypos + xpos = write_position.xpos + + screen.width = max(screen.width, xpos + virtual_width) + screen.height = max(screen.height, ypos + write_position.height) + + # Copy over window write positions. + self._copy_over_write_positions(screen, temp_screen, write_position) + + if temp_screen.show_cursor: + screen.show_cursor = True + + # Copy over cursor positions, if they are visible. + for window, point in temp_screen.cursor_positions.items(): + if ( + 0 <= point.x < write_position.width + and self.vertical_scroll + <= point.y + < write_position.height + self.vertical_scroll + ): + screen.cursor_positions[window] = Point( + x=point.x + xpos, y=point.y + ypos - self.vertical_scroll + ) + + # Copy over menu positions, but clip them to the visible area. + for window, point in temp_screen.menu_positions.items(): + screen.menu_positions[window] = self._clip_point_to_visible_area( + Point(x=point.x + xpos, y=point.y + ypos - self.vertical_scroll), + write_position, + ) + + # Draw scrollbar. + if show_scrollbar: + self._draw_scrollbar( + write_position, + virtual_height, + screen, + ) + + def _clip_point_to_visible_area( + self, point: Point, write_position: WritePosition + ) -> Point: + """ + Ensure that the cursor and menu positions always are always reported + """ + if point.x < write_position.xpos: + point = point._replace(x=write_position.xpos) + if point.y < write_position.ypos: + point = point._replace(y=write_position.ypos) + if point.x >= write_position.xpos + write_position.width: + point = point._replace(x=write_position.xpos + write_position.width - 1) + if point.y >= write_position.ypos + write_position.height: + point = point._replace(y=write_position.ypos + write_position.height - 1) + + return point + + def _copy_over_screen( + self, + screen: Screen, + temp_screen: Screen, + write_position: WritePosition, + virtual_width: int, + ) -> None: + """ + Copy over visible screen content and "zero width escape sequences". + """ + ypos = write_position.ypos + xpos = write_position.xpos + + for y in range(write_position.height): + temp_row = temp_screen.data_buffer[y + self.vertical_scroll] + row = screen.data_buffer[y + ypos] + temp_zero_width_escapes = temp_screen.zero_width_escapes[ + y + self.vertical_scroll + ] + zero_width_escapes = screen.zero_width_escapes[y + ypos] + + for x in range(virtual_width): + row[x + xpos] = temp_row[x] + + if x in temp_zero_width_escapes: + zero_width_escapes[x + xpos] = temp_zero_width_escapes[x] + + def _copy_over_mouse_handlers( + self, + mouse_handlers: MouseHandlers, + temp_mouse_handlers: MouseHandlers, + write_position: WritePosition, + virtual_width: int, + ) -> None: + """ + Copy over mouse handlers from virtual screen to real screen. + + Note: we take `virtual_width` because we don't want to copy over mouse + handlers that we possibly have behind the scrollbar. + """ + ypos = write_position.ypos + xpos = write_position.xpos + + # Cache mouse handlers when wrapping them. Very often the same mouse + # handler is registered for many positions. + mouse_handler_wrappers: dict[MouseHandler, MouseHandler] = {} + + def wrap_mouse_handler(handler: MouseHandler) -> MouseHandler: + "Wrap mouse handler. Translate coordinates in `MouseEvent`." + if handler not in mouse_handler_wrappers: + + def new_handler(event: MouseEvent) -> None: + new_event = MouseEvent( + position=Point( + x=event.position.x - xpos, + y=event.position.y + self.vertical_scroll - ypos, + ), + event_type=event.event_type, + button=event.button, + modifiers=event.modifiers, + ) + handler(new_event) + + mouse_handler_wrappers[handler] = new_handler + return mouse_handler_wrappers[handler] + + # Copy handlers. + mouse_handlers_dict = mouse_handlers.mouse_handlers + temp_mouse_handlers_dict = temp_mouse_handlers.mouse_handlers + + for y in range(write_position.height): + if y in temp_mouse_handlers_dict: + temp_mouse_row = temp_mouse_handlers_dict[y + self.vertical_scroll] + mouse_row = mouse_handlers_dict[y + ypos] + for x in range(virtual_width): + if x in temp_mouse_row: + mouse_row[x + xpos] = wrap_mouse_handler(temp_mouse_row[x]) + + def _copy_over_write_positions( + self, screen: Screen, temp_screen: Screen, write_position: WritePosition + ) -> None: + """ + Copy over window write positions. + """ + ypos = write_position.ypos + xpos = write_position.xpos + + for win, write_pos in temp_screen.visible_windows_to_write_positions.items(): + screen.visible_windows_to_write_positions[win] = WritePosition( + xpos=write_pos.xpos + xpos, + ypos=write_pos.ypos + ypos - self.vertical_scroll, + # TODO: if the window is only partly visible, then truncate width/height. + # This could be important if we have nested ScrollablePanes. + height=write_pos.height, + width=write_pos.width, + ) + + def is_modal(self) -> bool: + return self.content.is_modal() + + def get_key_bindings(self) -> KeyBindingsBase | None: + return self.content.get_key_bindings() + + def get_children(self) -> list[Container]: + return [self.content] + + def _make_window_visible( + self, + visible_height: int, + virtual_height: int, + visible_win_write_pos: WritePosition, + cursor_position: Point | None, + ) -> None: + """ + Scroll the scrollable pane, so that this window becomes visible. + + :param visible_height: Height of this `ScrollablePane` that is rendered. + :param virtual_height: Height of the virtual, temp screen. + :param visible_win_write_pos: `WritePosition` of the nested window on the + temp screen. + :param cursor_position: The location of the cursor position of this + window on the temp screen. + """ + # Start with maximum allowed scroll range, and then reduce according to + # the focused window and cursor position. + min_scroll = 0 + max_scroll = virtual_height - visible_height + + if self.keep_cursor_visible(): + # Reduce min/max scroll according to the cursor in the focused window. + if cursor_position is not None: + offsets = self.scroll_offsets + cpos_min_scroll = ( + cursor_position.y - visible_height + 1 + offsets.bottom + ) + cpos_max_scroll = cursor_position.y - offsets.top + min_scroll = max(min_scroll, cpos_min_scroll) + max_scroll = max(0, min(max_scroll, cpos_max_scroll)) + + if self.keep_focused_window_visible(): + # Reduce min/max scroll according to focused window position. + # If the window is small enough, bot the top and bottom of the window + # should be visible. + if visible_win_write_pos.height <= visible_height: + window_min_scroll = ( + visible_win_write_pos.ypos + + visible_win_write_pos.height + - visible_height + ) + window_max_scroll = visible_win_write_pos.ypos + else: + # Window does not fit on the screen. Make sure at least the whole + # screen is occupied with this window, and nothing else is shown. + window_min_scroll = visible_win_write_pos.ypos + window_max_scroll = ( + visible_win_write_pos.ypos + + visible_win_write_pos.height + - visible_height + ) + + min_scroll = max(min_scroll, window_min_scroll) + max_scroll = min(max_scroll, window_max_scroll) + + if min_scroll > max_scroll: + min_scroll = max_scroll # Should not happen. + + # Finally, properly clip the vertical scroll. + if self.vertical_scroll > max_scroll: + self.vertical_scroll = max_scroll + if self.vertical_scroll < min_scroll: + self.vertical_scroll = min_scroll + + def _draw_scrollbar( + self, write_position: WritePosition, content_height: int, screen: Screen + ) -> None: + """ + Draw the scrollbar on the screen. + + Note: There is some code duplication with the `ScrollbarMargin` + implementation. + """ + + window_height = write_position.height + display_arrows = self.display_arrows() + + if display_arrows: + window_height -= 2 + + try: + fraction_visible = write_position.height / float(content_height) + fraction_above = self.vertical_scroll / float(content_height) + + scrollbar_height = int( + min(window_height, max(1, window_height * fraction_visible)) + ) + scrollbar_top = int(window_height * fraction_above) + except ZeroDivisionError: + return + else: + + def is_scroll_button(row: int) -> bool: + "True if we should display a button on this row." + return scrollbar_top <= row <= scrollbar_top + scrollbar_height + + xpos = write_position.xpos + write_position.width - 1 + ypos = write_position.ypos + data_buffer = screen.data_buffer + + # Up arrow. + if display_arrows: + data_buffer[ypos][xpos] = Char( + self.up_arrow_symbol, "class:scrollbar.arrow" + ) + ypos += 1 + + # Scrollbar body. + scrollbar_background = "class:scrollbar.background" + scrollbar_background_start = "class:scrollbar.background,scrollbar.start" + scrollbar_button = "class:scrollbar.button" + scrollbar_button_end = "class:scrollbar.button,scrollbar.end" + + for i in range(window_height): + style = "" + if is_scroll_button(i): + if not is_scroll_button(i + 1): + # Give the last cell a different style, because we want + # to underline this. + style = scrollbar_button_end + else: + style = scrollbar_button + else: + if is_scroll_button(i + 1): + style = scrollbar_background_start + else: + style = scrollbar_background + + data_buffer[ypos][xpos] = Char(" ", style) + ypos += 1 + + # Down arrow + if display_arrows: + data_buffer[ypos][xpos] = Char( + self.down_arrow_symbol, "class:scrollbar.arrow" + ) diff --git a/src/prompt_toolkit/layout/utils.py b/src/prompt_toolkit/layout/utils.py new file mode 100644 index 0000000..0f78f37 --- /dev/null +++ b/src/prompt_toolkit/layout/utils.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Iterable, List, TypeVar, cast, overload + +from prompt_toolkit.formatted_text.base import OneStyleAndTextTuple + +if TYPE_CHECKING: + from typing_extensions import SupportsIndex + +__all__ = [ + "explode_text_fragments", +] + +_T = TypeVar("_T", bound=OneStyleAndTextTuple) + + +class _ExplodedList(List[_T]): + """ + Wrapper around a list, that marks it as 'exploded'. + + As soon as items are added or the list is extended, the new items are + automatically exploded as well. + """ + + exploded = True + + def append(self, item: _T) -> None: + self.extend([item]) + + def extend(self, lst: Iterable[_T]) -> None: + super().extend(explode_text_fragments(lst)) + + def insert(self, index: SupportsIndex, item: _T) -> None: + raise NotImplementedError # TODO + + # TODO: When creating a copy() or [:], return also an _ExplodedList. + + @overload + def __setitem__(self, index: SupportsIndex, value: _T) -> None: + ... + + @overload + def __setitem__(self, index: slice, value: Iterable[_T]) -> None: + ... + + def __setitem__( + self, index: SupportsIndex | slice, value: _T | Iterable[_T] + ) -> None: + """ + Ensure that when `(style_str, 'long string')` is set, the string will be + exploded. + """ + if not isinstance(index, slice): + int_index = index.__index__() + index = slice(int_index, int_index + 1) + if isinstance(value, tuple): # In case of `OneStyleAndTextTuple`. + value = cast("List[_T]", [value]) + + super().__setitem__(index, explode_text_fragments(value)) + + +def explode_text_fragments(fragments: Iterable[_T]) -> _ExplodedList[_T]: + """ + Turn a list of (style_str, text) tuples into another list where each string is + exactly one character. + + It should be fine to call this function several times. Calling this on a + list that is already exploded, is a null operation. + + :param fragments: List of (style, text) tuples. + """ + # When the fragments is already exploded, don't explode again. + if isinstance(fragments, _ExplodedList): + return fragments + + result: list[_T] = [] + + for style, string, *rest in fragments: + for c in string: + result.append((style, c, *rest)) # type: ignore + + return _ExplodedList(result) diff --git a/src/prompt_toolkit/lexers/__init__.py b/src/prompt_toolkit/lexers/__init__.py new file mode 100644 index 0000000..9bdc599 --- /dev/null +++ b/src/prompt_toolkit/lexers/__init__.py @@ -0,0 +1,20 @@ +""" +Lexer interface and implementations. +Used for syntax highlighting. +""" +from __future__ import annotations + +from .base import DynamicLexer, Lexer, SimpleLexer +from .pygments import PygmentsLexer, RegexSync, SyncFromStart, SyntaxSync + +__all__ = [ + # Base. + "Lexer", + "SimpleLexer", + "DynamicLexer", + # Pygments. + "PygmentsLexer", + "RegexSync", + "SyncFromStart", + "SyntaxSync", +] diff --git a/src/prompt_toolkit/lexers/base.py b/src/prompt_toolkit/lexers/base.py new file mode 100644 index 0000000..3f65f8e --- /dev/null +++ b/src/prompt_toolkit/lexers/base.py @@ -0,0 +1,84 @@ +""" +Base classes for prompt_toolkit lexers. +""" +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from typing import Callable, Hashable + +from prompt_toolkit.document import Document +from prompt_toolkit.formatted_text.base import StyleAndTextTuples + +__all__ = [ + "Lexer", + "SimpleLexer", + "DynamicLexer", +] + + +class Lexer(metaclass=ABCMeta): + """ + Base class for all lexers. + """ + + @abstractmethod + def lex_document(self, document: Document) -> Callable[[int], StyleAndTextTuples]: + """ + Takes a :class:`~prompt_toolkit.document.Document` and returns a + callable that takes a line number and returns a list of + ``(style_str, text)`` tuples for that line. + + XXX: Note that in the past, this was supposed to return a list + of ``(Token, text)`` tuples, just like a Pygments lexer. + """ + + def invalidation_hash(self) -> Hashable: + """ + When this changes, `lex_document` could give a different output. + (Only used for `DynamicLexer`.) + """ + return id(self) + + +class SimpleLexer(Lexer): + """ + Lexer that doesn't do any tokenizing and returns the whole input as one + token. + + :param style: The style string for this lexer. + """ + + def __init__(self, style: str = "") -> None: + self.style = style + + def lex_document(self, document: Document) -> Callable[[int], StyleAndTextTuples]: + lines = document.lines + + def get_line(lineno: int) -> StyleAndTextTuples: + "Return the tokens for the given line." + try: + return [(self.style, lines[lineno])] + except IndexError: + return [] + + return get_line + + +class DynamicLexer(Lexer): + """ + Lexer class that can dynamically returns any Lexer. + + :param get_lexer: Callable that returns a :class:`.Lexer` instance. + """ + + def __init__(self, get_lexer: Callable[[], Lexer | None]) -> None: + self.get_lexer = get_lexer + self._dummy = SimpleLexer() + + def lex_document(self, document: Document) -> Callable[[int], StyleAndTextTuples]: + lexer = self.get_lexer() or self._dummy + return lexer.lex_document(document) + + def invalidation_hash(self) -> Hashable: + lexer = self.get_lexer() or self._dummy + return id(lexer) diff --git a/src/prompt_toolkit/lexers/pygments.py b/src/prompt_toolkit/lexers/pygments.py new file mode 100644 index 0000000..4721d73 --- /dev/null +++ b/src/prompt_toolkit/lexers/pygments.py @@ -0,0 +1,327 @@ +""" +Adaptor classes for using Pygments lexers within prompt_toolkit. + +This includes syntax synchronization code, so that we don't have to start +lexing at the beginning of a document, when displaying a very large text. +""" +from __future__ import annotations + +import re +from abc import ABCMeta, abstractmethod +from typing import TYPE_CHECKING, Callable, Dict, Generator, Iterable, Tuple + +from prompt_toolkit.document import Document +from prompt_toolkit.filters import FilterOrBool, to_filter +from prompt_toolkit.formatted_text.base import StyleAndTextTuples +from prompt_toolkit.formatted_text.utils import split_lines +from prompt_toolkit.styles.pygments import pygments_token_to_classname + +from .base import Lexer, SimpleLexer + +if TYPE_CHECKING: + from pygments.lexer import Lexer as PygmentsLexerCls + +__all__ = [ + "PygmentsLexer", + "SyntaxSync", + "SyncFromStart", + "RegexSync", +] + + +class SyntaxSync(metaclass=ABCMeta): + """ + Syntax synchronizer. This is a tool that finds a start position for the + lexer. This is especially important when editing big documents; we don't + want to start the highlighting by running the lexer from the beginning of + the file. That is very slow when editing. + """ + + @abstractmethod + def get_sync_start_position( + self, document: Document, lineno: int + ) -> tuple[int, int]: + """ + Return the position from where we can start lexing as a (row, column) + tuple. + + :param document: `Document` instance that contains all the lines. + :param lineno: The line that we want to highlight. (We need to return + this line, or an earlier position.) + """ + + +class SyncFromStart(SyntaxSync): + """ + Always start the syntax highlighting from the beginning. + """ + + def get_sync_start_position( + self, document: Document, lineno: int + ) -> tuple[int, int]: + return 0, 0 + + +class RegexSync(SyntaxSync): + """ + Synchronize by starting at a line that matches the given regex pattern. + """ + + # Never go more than this amount of lines backwards for synchronization. + # That would be too CPU intensive. + MAX_BACKWARDS = 500 + + # Start lexing at the start, if we are in the first 'n' lines and no + # synchronization position was found. + FROM_START_IF_NO_SYNC_POS_FOUND = 100 + + def __init__(self, pattern: str) -> None: + self._compiled_pattern = re.compile(pattern) + + def get_sync_start_position( + self, document: Document, lineno: int + ) -> tuple[int, int]: + """ + Scan backwards, and find a possible position to start. + """ + pattern = self._compiled_pattern + lines = document.lines + + # Scan upwards, until we find a point where we can start the syntax + # synchronization. + for i in range(lineno, max(-1, lineno - self.MAX_BACKWARDS), -1): + match = pattern.match(lines[i]) + if match: + return i, match.start() + + # No synchronization point found. If we aren't that far from the + # beginning, start at the very beginning, otherwise, just try to start + # at the current line. + if lineno < self.FROM_START_IF_NO_SYNC_POS_FOUND: + return 0, 0 + else: + return lineno, 0 + + @classmethod + def from_pygments_lexer_cls(cls, lexer_cls: PygmentsLexerCls) -> RegexSync: + """ + Create a :class:`.RegexSync` instance for this Pygments lexer class. + """ + patterns = { + # For Python, start highlighting at any class/def block. + "Python": r"^\s*(class|def)\s+", + "Python 3": r"^\s*(class|def)\s+", + # For HTML, start at any open/close tag definition. + "HTML": r"<[/a-zA-Z]", + # For javascript, start at a function. + "JavaScript": r"\bfunction\b", + # TODO: Add definitions for other languages. + # By default, we start at every possible line. + } + p = patterns.get(lexer_cls.name, "^") + return cls(p) + + +class _TokenCache(Dict[Tuple[str, ...], str]): + """ + Cache that converts Pygments tokens into `prompt_toolkit` style objects. + + ``Token.A.B.C`` will be converted into: + ``class:pygments,pygments.A,pygments.A.B,pygments.A.B.C`` + """ + + def __missing__(self, key: tuple[str, ...]) -> str: + result = "class:" + pygments_token_to_classname(key) + self[key] = result + return result + + +_token_cache = _TokenCache() + + +class PygmentsLexer(Lexer): + """ + Lexer that calls a pygments lexer. + + Example:: + + from pygments.lexers.html import HtmlLexer + lexer = PygmentsLexer(HtmlLexer) + + Note: Don't forget to also load a Pygments compatible style. E.g.:: + + from prompt_toolkit.styles.from_pygments import style_from_pygments_cls + from pygments.styles import get_style_by_name + style = style_from_pygments_cls(get_style_by_name('monokai')) + + :param pygments_lexer_cls: A `Lexer` from Pygments. + :param sync_from_start: Start lexing at the start of the document. This + will always give the best results, but it will be slow for bigger + documents. (When the last part of the document is display, then the + whole document will be lexed by Pygments on every key stroke.) It is + recommended to disable this for inputs that are expected to be more + than 1,000 lines. + :param syntax_sync: `SyntaxSync` object. + """ + + # Minimum amount of lines to go backwards when starting the parser. + # This is important when the lines are retrieved in reverse order, or when + # scrolling upwards. (Due to the complexity of calculating the vertical + # scroll offset in the `Window` class, lines are not always retrieved in + # order.) + MIN_LINES_BACKWARDS = 50 + + # When a parser was started this amount of lines back, read the parser + # until we get the current line. Otherwise, start a new parser. + # (This should probably be bigger than MIN_LINES_BACKWARDS.) + REUSE_GENERATOR_MAX_DISTANCE = 100 + + def __init__( + self, + pygments_lexer_cls: type[PygmentsLexerCls], + sync_from_start: FilterOrBool = True, + syntax_sync: SyntaxSync | None = None, + ) -> None: + self.pygments_lexer_cls = pygments_lexer_cls + self.sync_from_start = to_filter(sync_from_start) + + # Instantiate the Pygments lexer. + self.pygments_lexer = pygments_lexer_cls( + stripnl=False, stripall=False, ensurenl=False + ) + + # Create syntax sync instance. + self.syntax_sync = syntax_sync or RegexSync.from_pygments_lexer_cls( + pygments_lexer_cls + ) + + @classmethod + def from_filename( + cls, filename: str, sync_from_start: FilterOrBool = True + ) -> Lexer: + """ + Create a `Lexer` from a filename. + """ + # Inline imports: the Pygments dependency is optional! + from pygments.lexers import get_lexer_for_filename + from pygments.util import ClassNotFound + + try: + pygments_lexer = get_lexer_for_filename(filename) + except ClassNotFound: + return SimpleLexer() + else: + return cls(pygments_lexer.__class__, sync_from_start=sync_from_start) + + def lex_document(self, document: Document) -> Callable[[int], StyleAndTextTuples]: + """ + Create a lexer function that takes a line number and returns the list + of (style_str, text) tuples as the Pygments lexer returns for that line. + """ + LineGenerator = Generator[Tuple[int, StyleAndTextTuples], None, None] + + # Cache of already lexed lines. + cache: dict[int, StyleAndTextTuples] = {} + + # Pygments generators that are currently lexing. + # Map lexer generator to the line number. + line_generators: dict[LineGenerator, int] = {} + + def get_syntax_sync() -> SyntaxSync: + "The Syntax synchronization object that we currently use." + if self.sync_from_start(): + return SyncFromStart() + else: + return self.syntax_sync + + def find_closest_generator(i: int) -> LineGenerator | None: + "Return a generator close to line 'i', or None if none was found." + for generator, lineno in line_generators.items(): + if lineno < i and i - lineno < self.REUSE_GENERATOR_MAX_DISTANCE: + return generator + return None + + def create_line_generator(start_lineno: int, column: int = 0) -> LineGenerator: + """ + Create a generator that yields the lexed lines. + Each iteration it yields a (line_number, [(style_str, text), ...]) tuple. + """ + + def get_text_fragments() -> Iterable[tuple[str, str]]: + text = "\n".join(document.lines[start_lineno:])[column:] + + # We call `get_text_fragments_unprocessed`, because `get_tokens` will + # still replace \r\n and \r by \n. (We don't want that, + # Pygments should return exactly the same amount of text, as we + # have given as input.) + for _, t, v in self.pygments_lexer.get_tokens_unprocessed(text): + # Turn Pygments `Token` object into prompt_toolkit style + # strings. + yield _token_cache[t], v + + yield from enumerate(split_lines(list(get_text_fragments())), start_lineno) + + def get_generator(i: int) -> LineGenerator: + """ + Find an already started generator that is close, or create a new one. + """ + # Find closest line generator. + generator = find_closest_generator(i) + if generator: + return generator + + # No generator found. Determine starting point for the syntax + # synchronization first. + + # Go at least x lines back. (Make scrolling upwards more + # efficient.) + i = max(0, i - self.MIN_LINES_BACKWARDS) + + if i == 0: + row = 0 + column = 0 + else: + row, column = get_syntax_sync().get_sync_start_position(document, i) + + # Find generator close to this point, or otherwise create a new one. + generator = find_closest_generator(i) + if generator: + return generator + else: + generator = create_line_generator(row, column) + + # If the column is not 0, ignore the first line. (Which is + # incomplete. This happens when the synchronization algorithm tells + # us to start parsing in the middle of a line.) + if column: + next(generator) + row += 1 + + line_generators[generator] = row + return generator + + def get_line(i: int) -> StyleAndTextTuples: + "Return the tokens for a given line number." + try: + return cache[i] + except KeyError: + generator = get_generator(i) + + # Exhaust the generator, until we find the requested line. + for num, line in generator: + cache[num] = line + if num == i: + line_generators[generator] = i + + # Remove the next item from the cache. + # (It could happen that it's already there, because of + # another generator that started filling these lines, + # but we want to synchronize these lines with the + # current lexer's state.) + if num + 1 in cache: + del cache[num + 1] + + return cache[num] + return [] + + return get_line diff --git a/src/prompt_toolkit/log.py b/src/prompt_toolkit/log.py new file mode 100644 index 0000000..adb5172 --- /dev/null +++ b/src/prompt_toolkit/log.py @@ -0,0 +1,12 @@ +""" +Logging configuration. +""" +from __future__ import annotations + +import logging + +__all__ = [ + "logger", +] + +logger = logging.getLogger(__package__) diff --git a/src/prompt_toolkit/mouse_events.py b/src/prompt_toolkit/mouse_events.py new file mode 100644 index 0000000..743773b --- /dev/null +++ b/src/prompt_toolkit/mouse_events.py @@ -0,0 +1,89 @@ +""" +Mouse events. + + +How it works +------------ + +The renderer has a 2 dimensional grid of mouse event handlers. +(`prompt_toolkit.layout.MouseHandlers`.) When the layout is rendered, the +`Window` class will make sure that this grid will also be filled with +callbacks. For vt100 terminals, mouse events are received through stdin, just +like any other key press. There is a handler among the key bindings that +catches these events and forwards them to such a mouse event handler. It passes +through the `Window` class where the coordinates are translated from absolute +coordinates to coordinates relative to the user control, and there +`UIControl.mouse_handler` is called. +""" +from __future__ import annotations + +from enum import Enum + +from .data_structures import Point + +__all__ = ["MouseEventType", "MouseButton", "MouseModifier", "MouseEvent"] + + +class MouseEventType(Enum): + # Mouse up: This same event type is fired for all three events: left mouse + # up, right mouse up, or middle mouse up + MOUSE_UP = "MOUSE_UP" + + # Mouse down: This implicitly refers to the left mouse down (this event is + # not fired upon pressing the middle or right mouse buttons). + MOUSE_DOWN = "MOUSE_DOWN" + + SCROLL_UP = "SCROLL_UP" + SCROLL_DOWN = "SCROLL_DOWN" + + # Triggered when the left mouse button is held down, and the mouse moves + MOUSE_MOVE = "MOUSE_MOVE" + + +class MouseButton(Enum): + LEFT = "LEFT" + MIDDLE = "MIDDLE" + RIGHT = "RIGHT" + + # When we're scrolling, or just moving the mouse and not pressing a button. + NONE = "NONE" + + # This is for when we don't know which mouse button was pressed, but we do + # know that one has been pressed during this mouse event (as opposed to + # scrolling, for example) + UNKNOWN = "UNKNOWN" + + +class MouseModifier(Enum): + SHIFT = "SHIFT" + ALT = "ALT" + CONTROL = "CONTROL" + + +class MouseEvent: + """ + Mouse event, sent to `UIControl.mouse_handler`. + + :param position: `Point` instance. + :param event_type: `MouseEventType`. + """ + + def __init__( + self, + position: Point, + event_type: MouseEventType, + button: MouseButton, + modifiers: frozenset[MouseModifier], + ) -> None: + self.position = position + self.event_type = event_type + self.button = button + self.modifiers = modifiers + + def __repr__(self) -> str: + return "MouseEvent({!r},{!r},{!r},{!r})".format( + self.position, + self.event_type, + self.button, + self.modifiers, + ) diff --git a/src/prompt_toolkit/output/__init__.py b/src/prompt_toolkit/output/__init__.py new file mode 100644 index 0000000..6b4c5f3 --- /dev/null +++ b/src/prompt_toolkit/output/__init__.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from .base import DummyOutput, Output +from .color_depth import ColorDepth +from .defaults import create_output + +__all__ = [ + # Base. + "Output", + "DummyOutput", + # Color depth. + "ColorDepth", + # Defaults. + "create_output", +] diff --git a/src/prompt_toolkit/output/base.py b/src/prompt_toolkit/output/base.py new file mode 100644 index 0000000..3c38cec --- /dev/null +++ b/src/prompt_toolkit/output/base.py @@ -0,0 +1,331 @@ +""" +Interface for an output. +""" +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from typing import TextIO + +from prompt_toolkit.cursor_shapes import CursorShape +from prompt_toolkit.data_structures import Size +from prompt_toolkit.styles import Attrs + +from .color_depth import ColorDepth + +__all__ = [ + "Output", + "DummyOutput", +] + + +class Output(metaclass=ABCMeta): + """ + Base class defining the output interface for a + :class:`~prompt_toolkit.renderer.Renderer`. + + Actual implementations are + :class:`~prompt_toolkit.output.vt100.Vt100_Output` and + :class:`~prompt_toolkit.output.win32.Win32Output`. + """ + + stdout: TextIO | None = None + + @abstractmethod + def fileno(self) -> int: + "Return the file descriptor to which we can write for the output." + + @abstractmethod + def encoding(self) -> str: + """ + Return the encoding for this output, e.g. 'utf-8'. + (This is used mainly to know which characters are supported by the + output the data, so that the UI can provide alternatives, when + required.) + """ + + @abstractmethod + def write(self, data: str) -> None: + "Write text (Terminal escape sequences will be removed/escaped.)" + + @abstractmethod + def write_raw(self, data: str) -> None: + "Write text." + + @abstractmethod + def set_title(self, title: str) -> None: + "Set terminal title." + + @abstractmethod + def clear_title(self) -> None: + "Clear title again. (or restore previous title.)" + + @abstractmethod + def flush(self) -> None: + "Write to output stream and flush." + + @abstractmethod + def erase_screen(self) -> None: + """ + Erases the screen with the background color and moves the cursor to + home. + """ + + @abstractmethod + def enter_alternate_screen(self) -> None: + "Go to the alternate screen buffer. (For full screen applications)." + + @abstractmethod + def quit_alternate_screen(self) -> None: + "Leave the alternate screen buffer." + + @abstractmethod + def enable_mouse_support(self) -> None: + "Enable mouse." + + @abstractmethod + def disable_mouse_support(self) -> None: + "Disable mouse." + + @abstractmethod + def erase_end_of_line(self) -> None: + """ + Erases from the current cursor position to the end of the current line. + """ + + @abstractmethod + def erase_down(self) -> None: + """ + Erases the screen from the current line down to the bottom of the + screen. + """ + + @abstractmethod + def reset_attributes(self) -> None: + "Reset color and styling attributes." + + @abstractmethod + def set_attributes(self, attrs: Attrs, color_depth: ColorDepth) -> None: + "Set new color and styling attributes." + + @abstractmethod + def disable_autowrap(self) -> None: + "Disable auto line wrapping." + + @abstractmethod + def enable_autowrap(self) -> None: + "Enable auto line wrapping." + + @abstractmethod + def cursor_goto(self, row: int = 0, column: int = 0) -> None: + "Move cursor position." + + @abstractmethod + def cursor_up(self, amount: int) -> None: + "Move cursor `amount` place up." + + @abstractmethod + def cursor_down(self, amount: int) -> None: + "Move cursor `amount` place down." + + @abstractmethod + def cursor_forward(self, amount: int) -> None: + "Move cursor `amount` place forward." + + @abstractmethod + def cursor_backward(self, amount: int) -> None: + "Move cursor `amount` place backward." + + @abstractmethod + def hide_cursor(self) -> None: + "Hide cursor." + + @abstractmethod + def show_cursor(self) -> None: + "Show cursor." + + @abstractmethod + def set_cursor_shape(self, cursor_shape: CursorShape) -> None: + "Set cursor shape to block, beam or underline." + + @abstractmethod + def reset_cursor_shape(self) -> None: + "Reset cursor shape." + + def ask_for_cpr(self) -> None: + """ + Asks for a cursor position report (CPR). + (VT100 only.) + """ + + @property + def responds_to_cpr(self) -> bool: + """ + `True` if the `Application` can expect to receive a CPR response after + calling `ask_for_cpr` (this will come back through the corresponding + `Input`). + + This is used to determine the amount of available rows we have below + the cursor position. In the first place, we have this so that the drop + down autocompletion menus are sized according to the available space. + + On Windows, we don't need this, there we have + `get_rows_below_cursor_position`. + """ + return False + + @abstractmethod + def get_size(self) -> Size: + "Return the size of the output window." + + def bell(self) -> None: + "Sound bell." + + def enable_bracketed_paste(self) -> None: + "For vt100 only." + + def disable_bracketed_paste(self) -> None: + "For vt100 only." + + def reset_cursor_key_mode(self) -> None: + """ + For vt100 only. + Put the terminal in normal cursor mode (instead of application mode). + + See: https://vt100.net/docs/vt100-ug/chapter3.html + """ + + def scroll_buffer_to_prompt(self) -> None: + "For Win32 only." + + def get_rows_below_cursor_position(self) -> int: + "For Windows only." + raise NotImplementedError + + @abstractmethod + def get_default_color_depth(self) -> ColorDepth: + """ + Get default color depth for this output. + + This value will be used if no color depth was explicitly passed to the + `Application`. + + .. note:: + + If the `$PROMPT_TOOLKIT_COLOR_DEPTH` environment variable has been + set, then `outputs.defaults.create_output` will pass this value to + the implementation as the default_color_depth, which is returned + here. (This is not used when the output corresponds to a + prompt_toolkit SSH/Telnet session.) + """ + + +class DummyOutput(Output): + """ + For testing. An output class that doesn't render anything. + """ + + def fileno(self) -> int: + "There is no sensible default for fileno()." + raise NotImplementedError + + def encoding(self) -> str: + return "utf-8" + + def write(self, data: str) -> None: + pass + + def write_raw(self, data: str) -> None: + pass + + def set_title(self, title: str) -> None: + pass + + def clear_title(self) -> None: + pass + + def flush(self) -> None: + pass + + def erase_screen(self) -> None: + pass + + def enter_alternate_screen(self) -> None: + pass + + def quit_alternate_screen(self) -> None: + pass + + def enable_mouse_support(self) -> None: + pass + + def disable_mouse_support(self) -> None: + pass + + def erase_end_of_line(self) -> None: + pass + + def erase_down(self) -> None: + pass + + def reset_attributes(self) -> None: + pass + + def set_attributes(self, attrs: Attrs, color_depth: ColorDepth) -> None: + pass + + def disable_autowrap(self) -> None: + pass + + def enable_autowrap(self) -> None: + pass + + def cursor_goto(self, row: int = 0, column: int = 0) -> None: + pass + + def cursor_up(self, amount: int) -> None: + pass + + def cursor_down(self, amount: int) -> None: + pass + + def cursor_forward(self, amount: int) -> None: + pass + + def cursor_backward(self, amount: int) -> None: + pass + + def hide_cursor(self) -> None: + pass + + def show_cursor(self) -> None: + pass + + def set_cursor_shape(self, cursor_shape: CursorShape) -> None: + pass + + def reset_cursor_shape(self) -> None: + pass + + def ask_for_cpr(self) -> None: + pass + + def bell(self) -> None: + pass + + def enable_bracketed_paste(self) -> None: + pass + + def disable_bracketed_paste(self) -> None: + pass + + def scroll_buffer_to_prompt(self) -> None: + pass + + def get_size(self) -> Size: + return Size(rows=40, columns=80) + + def get_rows_below_cursor_position(self) -> int: + return 40 + + def get_default_color_depth(self) -> ColorDepth: + return ColorDepth.DEPTH_1_BIT diff --git a/src/prompt_toolkit/output/color_depth.py b/src/prompt_toolkit/output/color_depth.py new file mode 100644 index 0000000..f66d2be --- /dev/null +++ b/src/prompt_toolkit/output/color_depth.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import os +from enum import Enum + +__all__ = [ + "ColorDepth", +] + + +class ColorDepth(str, Enum): + """ + Possible color depth values for the output. + """ + + value: str + + #: One color only. + DEPTH_1_BIT = "DEPTH_1_BIT" + + #: ANSI Colors. + DEPTH_4_BIT = "DEPTH_4_BIT" + + #: The default. + DEPTH_8_BIT = "DEPTH_8_BIT" + + #: 24 bit True color. + DEPTH_24_BIT = "DEPTH_24_BIT" + + # Aliases. + MONOCHROME = DEPTH_1_BIT + ANSI_COLORS_ONLY = DEPTH_4_BIT + DEFAULT = DEPTH_8_BIT + TRUE_COLOR = DEPTH_24_BIT + + @classmethod + def from_env(cls) -> ColorDepth | None: + """ + Return the color depth if the $PROMPT_TOOLKIT_COLOR_DEPTH environment + variable has been set. + + This is a way to enforce a certain color depth in all prompt_toolkit + applications. + """ + # Disable color if a `NO_COLOR` environment variable is set. + # See: https://no-color.org/ + if os.environ.get("NO_COLOR"): + return cls.DEPTH_1_BIT + + # Check the `PROMPT_TOOLKIT_COLOR_DEPTH` environment variable. + all_values = [i.value for i in ColorDepth] + if os.environ.get("PROMPT_TOOLKIT_COLOR_DEPTH") in all_values: + return cls(os.environ["PROMPT_TOOLKIT_COLOR_DEPTH"]) + + return None + + @classmethod + def default(cls) -> ColorDepth: + """ + Return the default color depth for the default output. + """ + from .defaults import create_output + + return create_output().get_default_color_depth() diff --git a/src/prompt_toolkit/output/conemu.py b/src/prompt_toolkit/output/conemu.py new file mode 100644 index 0000000..6369944 --- /dev/null +++ b/src/prompt_toolkit/output/conemu.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import sys + +assert sys.platform == "win32" + +from typing import Any, TextIO + +from prompt_toolkit.data_structures import Size + +from .base import Output +from .color_depth import ColorDepth +from .vt100 import Vt100_Output +from .win32 import Win32Output + +__all__ = [ + "ConEmuOutput", +] + + +class ConEmuOutput: + """ + ConEmu (Windows) output abstraction. + + ConEmu is a Windows console application, but it also supports ANSI escape + sequences. This output class is actually a proxy to both `Win32Output` and + `Vt100_Output`. It uses `Win32Output` for console sizing and scrolling, but + all cursor movements and scrolling happens through the `Vt100_Output`. + + This way, we can have 256 colors in ConEmu and Cmder. Rendering will be + even a little faster as well. + + http://conemu.github.io/ + http://gooseberrycreative.com/cmder/ + """ + + def __init__( + self, stdout: TextIO, default_color_depth: ColorDepth | None = None + ) -> None: + self.win32_output = Win32Output(stdout, default_color_depth=default_color_depth) + self.vt100_output = Vt100_Output( + stdout, lambda: Size(0, 0), default_color_depth=default_color_depth + ) + + @property + def responds_to_cpr(self) -> bool: + return False # We don't need this on Windows. + + def __getattr__(self, name: str) -> Any: + 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", + ): + return getattr(self.win32_output, name) + else: + return getattr(self.vt100_output, name) + + +Output.register(ConEmuOutput) diff --git a/src/prompt_toolkit/output/defaults.py b/src/prompt_toolkit/output/defaults.py new file mode 100644 index 0000000..ed114e3 --- /dev/null +++ b/src/prompt_toolkit/output/defaults.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import sys +from typing import TextIO, cast + +from prompt_toolkit.utils import ( + get_bell_environment_variable, + get_term_environment_variable, + is_conemu_ansi, +) + +from .base import DummyOutput, Output +from .color_depth import ColorDepth +from .plain_text import PlainTextOutput + +__all__ = [ + "create_output", +] + + +def create_output( + stdout: TextIO | None = None, always_prefer_tty: bool = False +) -> Output: + """ + Return an :class:`~prompt_toolkit.output.Output` instance for the command + line. + + :param stdout: The stdout object + :param always_prefer_tty: When set, look for `sys.stderr` if `sys.stdout` + is not a TTY. Useful if `sys.stdout` is redirected to a file, but we + still want user input and output on the terminal. + + By default, this is `False`. If `sys.stdout` is not a terminal (maybe + it's redirected to a file), then a `PlainTextOutput` will be returned. + That way, tools like `print_formatted_text` will write plain text into + that file. + """ + # Consider TERM, PROMPT_TOOLKIT_BELL, and PROMPT_TOOLKIT_COLOR_DEPTH + # environment variables. Notice that PROMPT_TOOLKIT_COLOR_DEPTH value is + # the default that's used if the Application doesn't override it. + term_from_env = get_term_environment_variable() + bell_from_env = get_bell_environment_variable() + color_depth_from_env = ColorDepth.from_env() + + if stdout is None: + # By default, render to stdout. If the output is piped somewhere else, + # render to stderr. + stdout = sys.stdout + + if always_prefer_tty: + for io in [sys.stdout, sys.stderr]: + if io is not None and io.isatty(): + # (This is `None` when using `pythonw.exe` on Windows.) + 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. + from prompt_toolkit.patch_stdout import StdoutProxy + + while isinstance(stdout, StdoutProxy): + stdout = stdout.original_stdout + + if sys.platform == "win32": + from .conemu import ConEmuOutput + from .win32 import Win32Output + from .windows10 import Windows10_Output, is_win_vt100_enabled + + if is_win_vt100_enabled(): + return cast( + Output, + Windows10_Output(stdout, default_color_depth=color_depth_from_env), + ) + if is_conemu_ansi(): + return cast( + Output, ConEmuOutput(stdout, default_color_depth=color_depth_from_env) + ) + else: + return Win32Output(stdout, default_color_depth=color_depth_from_env) + else: + from .vt100 import Vt100_Output + + # Stdout is not a TTY? Render as plain text. + # This is mostly useful if stdout is redirected to a file, and + # `print_formatted_text` is used. + if not stdout.isatty(): + return PlainTextOutput(stdout) + + return Vt100_Output.from_pty( + stdout, + term=term_from_env, + default_color_depth=color_depth_from_env, + enable_bell=bell_from_env, + ) diff --git a/src/prompt_toolkit/output/flush_stdout.py b/src/prompt_toolkit/output/flush_stdout.py new file mode 100644 index 0000000..daf58ef --- /dev/null +++ b/src/prompt_toolkit/output/flush_stdout.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import errno +import os +import sys +from contextlib import contextmanager +from typing import IO, Iterator, TextIO + +__all__ = ["flush_stdout"] + + +def flush_stdout(stdout: TextIO, data: str) -> None: + # If the IO object has an `encoding` and `buffer` attribute, it means that + # we can access the underlying BinaryIO object and write into it in binary + # mode. This is preferred if possible. + # NOTE: When used in a Jupyter notebook, don't write binary. + # `ipykernel.iostream.OutStream` has an `encoding` attribute, but not + # a `buffer` attribute, so we can't write binary in it. + has_binary_io = hasattr(stdout, "encoding") and hasattr(stdout, "buffer") + + try: + # Ensure that `stdout` is made blocking when writing into it. + # Otherwise, when uvloop is activated (which makes stdout + # non-blocking), and we write big amounts of text, then we get a + # `BlockingIOError` here. + with _blocking_io(stdout): + # (We try to encode ourself, because that way we can replace + # characters that don't exist in the character set, avoiding + # UnicodeEncodeError crashes. E.g. u'\xb7' does not appear in 'ascii'.) + # My Arch Linux installation of july 2015 reported 'ANSI_X3.4-1968' + # for sys.stdout.encoding in xterm. + if has_binary_io: + stdout.buffer.write(data.encode(stdout.encoding or "utf-8", "replace")) + else: + stdout.write(data) + + stdout.flush() + except OSError as e: + if e.args and e.args[0] == errno.EINTR: + # Interrupted system call. Can happen in case of a window + # resize signal. (Just ignore. The resize handler will render + # again anyway.) + pass + elif e.args and e.args[0] == 0: + # This can happen when there is a lot of output and the user + # sends a KeyboardInterrupt by pressing Control-C. E.g. in + # a Python REPL when we execute "while True: print('test')". + # (The `ptpython` REPL uses this `Output` class instead of + # `stdout` directly -- in order to be network transparent.) + # So, just ignore. + pass + else: + raise + + +@contextmanager +def _blocking_io(io: IO[str]) -> Iterator[None]: + """ + Ensure that the FD for `io` is set to blocking in here. + """ + if sys.platform == "win32": + # On Windows, the `os` module doesn't have a `get/set_blocking` + # function. + yield + return + + try: + fd = io.fileno() + blocking = os.get_blocking(fd) + except: # noqa + # Failed somewhere. + # `get_blocking` can raise `OSError`. + # The io object can raise `AttributeError` when no `fileno()` method is + # present if we're not a real file object. + blocking = True # Assume we're good, and don't do anything. + + try: + # Make blocking if we weren't blocking yet. + if not blocking: + os.set_blocking(fd, True) + + yield + + finally: + # Restore original blocking mode. + if not blocking: + os.set_blocking(fd, blocking) diff --git a/src/prompt_toolkit/output/plain_text.py b/src/prompt_toolkit/output/plain_text.py new file mode 100644 index 0000000..4b24ad9 --- /dev/null +++ b/src/prompt_toolkit/output/plain_text.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from typing import TextIO + +from prompt_toolkit.cursor_shapes import CursorShape +from prompt_toolkit.data_structures import Size +from prompt_toolkit.styles import Attrs + +from .base import Output +from .color_depth import ColorDepth +from .flush_stdout import flush_stdout + +__all__ = ["PlainTextOutput"] + + +class PlainTextOutput(Output): + """ + Output that won't include any ANSI escape sequences. + + Useful when stdout is not a terminal. Maybe stdout is redirected to a file. + In this case, if `print_formatted_text` is used, for instance, we don't + want to include formatting. + + (The code is mostly identical to `Vt100_Output`, but without the + formatting.) + """ + + def __init__(self, stdout: TextIO) -> None: + assert all(hasattr(stdout, a) for a in ("write", "flush")) + + self.stdout: TextIO = stdout + self._buffer: list[str] = [] + + def fileno(self) -> int: + "There is no sensible default for fileno()." + return self.stdout.fileno() + + def encoding(self) -> str: + return "utf-8" + + def write(self, data: str) -> None: + self._buffer.append(data) + + def write_raw(self, data: str) -> None: + self._buffer.append(data) + + def set_title(self, title: str) -> None: + pass + + def clear_title(self) -> None: + pass + + def flush(self) -> None: + if not self._buffer: + return + + data = "".join(self._buffer) + self._buffer = [] + flush_stdout(self.stdout, data) + + def erase_screen(self) -> None: + pass + + def enter_alternate_screen(self) -> None: + pass + + def quit_alternate_screen(self) -> None: + pass + + def enable_mouse_support(self) -> None: + pass + + def disable_mouse_support(self) -> None: + pass + + def erase_end_of_line(self) -> None: + pass + + def erase_down(self) -> None: + pass + + def reset_attributes(self) -> None: + pass + + def set_attributes(self, attrs: Attrs, color_depth: ColorDepth) -> None: + pass + + def disable_autowrap(self) -> None: + pass + + def enable_autowrap(self) -> None: + pass + + def cursor_goto(self, row: int = 0, column: int = 0) -> None: + pass + + def cursor_up(self, amount: int) -> None: + pass + + def cursor_down(self, amount: int) -> None: + self._buffer.append("\n") + + def cursor_forward(self, amount: int) -> None: + self._buffer.append(" " * amount) + + def cursor_backward(self, amount: int) -> None: + pass + + def hide_cursor(self) -> None: + pass + + def show_cursor(self) -> None: + pass + + def set_cursor_shape(self, cursor_shape: CursorShape) -> None: + pass + + def reset_cursor_shape(self) -> None: + pass + + def ask_for_cpr(self) -> None: + pass + + def bell(self) -> None: + pass + + def enable_bracketed_paste(self) -> None: + pass + + def disable_bracketed_paste(self) -> None: + pass + + def scroll_buffer_to_prompt(self) -> None: + pass + + def get_size(self) -> Size: + return Size(rows=40, columns=80) + + def get_rows_below_cursor_position(self) -> int: + return 8 + + def get_default_color_depth(self) -> ColorDepth: + return ColorDepth.DEPTH_1_BIT diff --git a/src/prompt_toolkit/output/vt100.py b/src/prompt_toolkit/output/vt100.py new file mode 100644 index 0000000..142deab --- /dev/null +++ b/src/prompt_toolkit/output/vt100.py @@ -0,0 +1,747 @@ +""" +Output for vt100 terminals. + +A lot of thanks, regarding outputting of colors, goes to the Pygments project: +(We don't rely on Pygments anymore, because many things are very custom, and +everything has been highly optimized.) +http://pygments.org/ +""" +from __future__ import annotations + +import io +import os +import sys +from typing import Callable, Dict, Hashable, Iterable, Sequence, TextIO, Tuple + +from prompt_toolkit.cursor_shapes import CursorShape +from prompt_toolkit.data_structures import Size +from prompt_toolkit.output import Output +from prompt_toolkit.styles import ANSI_COLOR_NAMES, Attrs +from prompt_toolkit.utils import is_dumb_terminal + +from .color_depth import ColorDepth +from .flush_stdout import flush_stdout + +__all__ = [ + "Vt100_Output", +] + + +FG_ANSI_COLORS = { + "ansidefault": 39, + # Low intensity. + "ansiblack": 30, + "ansired": 31, + "ansigreen": 32, + "ansiyellow": 33, + "ansiblue": 34, + "ansimagenta": 35, + "ansicyan": 36, + "ansigray": 37, + # High intensity. + "ansibrightblack": 90, + "ansibrightred": 91, + "ansibrightgreen": 92, + "ansibrightyellow": 93, + "ansibrightblue": 94, + "ansibrightmagenta": 95, + "ansibrightcyan": 96, + "ansiwhite": 97, +} + +BG_ANSI_COLORS = { + "ansidefault": 49, + # Low intensity. + "ansiblack": 40, + "ansired": 41, + "ansigreen": 42, + "ansiyellow": 43, + "ansiblue": 44, + "ansimagenta": 45, + "ansicyan": 46, + "ansigray": 47, + # High intensity. + "ansibrightblack": 100, + "ansibrightred": 101, + "ansibrightgreen": 102, + "ansibrightyellow": 103, + "ansibrightblue": 104, + "ansibrightmagenta": 105, + "ansibrightcyan": 106, + "ansiwhite": 107, +} + + +ANSI_COLORS_TO_RGB = { + "ansidefault": ( + 0x00, + 0x00, + 0x00, + ), # Don't use, 'default' doesn't really have a value. + "ansiblack": (0x00, 0x00, 0x00), + "ansigray": (0xE5, 0xE5, 0xE5), + "ansibrightblack": (0x7F, 0x7F, 0x7F), + "ansiwhite": (0xFF, 0xFF, 0xFF), + # Low intensity. + "ansired": (0xCD, 0x00, 0x00), + "ansigreen": (0x00, 0xCD, 0x00), + "ansiyellow": (0xCD, 0xCD, 0x00), + "ansiblue": (0x00, 0x00, 0xCD), + "ansimagenta": (0xCD, 0x00, 0xCD), + "ansicyan": (0x00, 0xCD, 0xCD), + # High intensity. + "ansibrightred": (0xFF, 0x00, 0x00), + "ansibrightgreen": (0x00, 0xFF, 0x00), + "ansibrightyellow": (0xFF, 0xFF, 0x00), + "ansibrightblue": (0x00, 0x00, 0xFF), + "ansibrightmagenta": (0xFF, 0x00, 0xFF), + "ansibrightcyan": (0x00, 0xFF, 0xFF), +} + + +assert set(FG_ANSI_COLORS) == set(ANSI_COLOR_NAMES) +assert set(BG_ANSI_COLORS) == set(ANSI_COLOR_NAMES) +assert set(ANSI_COLORS_TO_RGB) == set(ANSI_COLOR_NAMES) + + +def _get_closest_ansi_color(r: int, g: int, b: int, exclude: Sequence[str] = ()) -> str: + """ + Find closest ANSI color. Return it by name. + + :param r: Red (Between 0 and 255.) + :param g: Green (Between 0 and 255.) + :param b: Blue (Between 0 and 255.) + :param exclude: A tuple of color names to exclude. (E.g. ``('ansired', )``.) + """ + exclude = list(exclude) + + # When we have a bit of saturation, avoid the gray-like colors, otherwise, + # too often the distance to the gray color is less. + saturation = abs(r - g) + abs(g - b) + abs(b - r) # Between 0..510 + + if saturation > 30: + exclude.extend(["ansilightgray", "ansidarkgray", "ansiwhite", "ansiblack"]) + + # Take the closest color. + # (Thanks to Pygments for this part.) + distance = 257 * 257 * 3 # "infinity" (>distance from #000000 to #ffffff) + match = "ansidefault" + + for name, (r2, g2, b2) in ANSI_COLORS_TO_RGB.items(): + if name != "ansidefault" and name not in exclude: + d = (r - r2) ** 2 + (g - g2) ** 2 + (b - b2) ** 2 + + if d < distance: + match = name + distance = d + + return match + + +_ColorCodeAndName = Tuple[int, str] + + +class _16ColorCache: + """ + Cache which maps (r, g, b) tuples to 16 ansi colors. + + :param bg: Cache for background colors, instead of foreground. + """ + + def __init__(self, bg: bool = False) -> None: + self.bg = bg + self._cache: dict[Hashable, _ColorCodeAndName] = {} + + def get_code( + self, value: tuple[int, int, int], exclude: Sequence[str] = () + ) -> _ColorCodeAndName: + """ + Return a (ansi_code, ansi_name) tuple. (E.g. ``(44, 'ansiblue')``.) for + a given (r,g,b) value. + """ + key: Hashable = (value, tuple(exclude)) + cache = self._cache + + if key not in cache: + cache[key] = self._get(value, exclude) + + return cache[key] + + def _get( + self, value: tuple[int, int, int], exclude: Sequence[str] = () + ) -> _ColorCodeAndName: + r, g, b = value + match = _get_closest_ansi_color(r, g, b, exclude=exclude) + + # Turn color name into code. + if self.bg: + code = BG_ANSI_COLORS[match] + else: + code = FG_ANSI_COLORS[match] + + return code, match + + +class _256ColorCache(Dict[Tuple[int, int, int], int]): + """ + Cache which maps (r, g, b) tuples to 256 colors. + """ + + def __init__(self) -> None: + # Build color table. + colors: list[tuple[int, int, int]] = [] + + # colors 0..15: 16 basic colors + colors.append((0x00, 0x00, 0x00)) # 0 + colors.append((0xCD, 0x00, 0x00)) # 1 + colors.append((0x00, 0xCD, 0x00)) # 2 + colors.append((0xCD, 0xCD, 0x00)) # 3 + colors.append((0x00, 0x00, 0xEE)) # 4 + colors.append((0xCD, 0x00, 0xCD)) # 5 + colors.append((0x00, 0xCD, 0xCD)) # 6 + colors.append((0xE5, 0xE5, 0xE5)) # 7 + colors.append((0x7F, 0x7F, 0x7F)) # 8 + colors.append((0xFF, 0x00, 0x00)) # 9 + colors.append((0x00, 0xFF, 0x00)) # 10 + colors.append((0xFF, 0xFF, 0x00)) # 11 + colors.append((0x5C, 0x5C, 0xFF)) # 12 + colors.append((0xFF, 0x00, 0xFF)) # 13 + colors.append((0x00, 0xFF, 0xFF)) # 14 + colors.append((0xFF, 0xFF, 0xFF)) # 15 + + # colors 16..232: the 6x6x6 color cube + valuerange = (0x00, 0x5F, 0x87, 0xAF, 0xD7, 0xFF) + + for i in range(217): + r = valuerange[(i // 36) % 6] + g = valuerange[(i // 6) % 6] + b = valuerange[i % 6] + colors.append((r, g, b)) + + # colors 233..253: grayscale + for i in range(1, 22): + v = 8 + i * 10 + colors.append((v, v, v)) + + self.colors = colors + + def __missing__(self, value: tuple[int, int, int]) -> int: + r, g, b = value + + # Find closest color. + # (Thanks to Pygments for this!) + distance = 257 * 257 * 3 # "infinity" (>distance from #000000 to #ffffff) + match = 0 + + for i, (r2, g2, b2) in enumerate(self.colors): + if i >= 16: # XXX: We ignore the 16 ANSI colors when mapping RGB + # to the 256 colors, because these highly depend on + # the color scheme of the terminal. + d = (r - r2) ** 2 + (g - g2) ** 2 + (b - b2) ** 2 + + if d < distance: + match = i + distance = d + + # Turn color name into code. + self[value] = match + return match + + +_16_fg_colors = _16ColorCache(bg=False) +_16_bg_colors = _16ColorCache(bg=True) +_256_colors = _256ColorCache() + + +class _EscapeCodeCache(Dict[Attrs, str]): + """ + Cache for VT100 escape codes. It maps + (fgcolor, bgcolor, bold, underline, strike, reverse) tuples to VT100 + escape sequences. + + :param true_color: When True, use 24bit colors instead of 256 colors. + """ + + def __init__(self, color_depth: ColorDepth) -> None: + self.color_depth = color_depth + + def __missing__(self, attrs: Attrs) -> str: + ( + fgcolor, + bgcolor, + bold, + underline, + strike, + italic, + blink, + reverse, + hidden, + ) = attrs + parts: list[str] = [] + + parts.extend(self._colors_to_code(fgcolor or "", bgcolor or "")) + + if bold: + parts.append("1") + if italic: + parts.append("3") + if blink: + parts.append("5") + if underline: + parts.append("4") + if reverse: + parts.append("7") + if hidden: + parts.append("8") + if strike: + parts.append("9") + + if parts: + result = "\x1b[0;" + ";".join(parts) + "m" + else: + result = "\x1b[0m" + + self[attrs] = result + return result + + def _color_name_to_rgb(self, color: str) -> tuple[int, int, int]: + "Turn 'ffffff', into (0xff, 0xff, 0xff)." + try: + rgb = int(color, 16) + except ValueError: + raise + else: + r = (rgb >> 16) & 0xFF + g = (rgb >> 8) & 0xFF + b = rgb & 0xFF + return r, g, b + + def _colors_to_code(self, fg_color: str, bg_color: str) -> Iterable[str]: + """ + Return a tuple with the vt100 values that represent this color. + """ + # When requesting ANSI colors only, and both fg/bg color were converted + # to ANSI, ensure that the foreground and background color are not the + # same. (Unless they were explicitly defined to be the same color.) + fg_ansi = "" + + def get(color: str, bg: bool) -> list[int]: + nonlocal fg_ansi + + table = BG_ANSI_COLORS if bg else FG_ANSI_COLORS + + if not color or self.color_depth == ColorDepth.DEPTH_1_BIT: + return [] + + # 16 ANSI colors. (Given by name.) + elif color in table: + return [table[color]] + + # RGB colors. (Defined as 'ffffff'.) + else: + try: + rgb = self._color_name_to_rgb(color) + except ValueError: + return [] + + # When only 16 colors are supported, use that. + if self.color_depth == ColorDepth.DEPTH_4_BIT: + if bg: # Background. + if fg_color != bg_color: + exclude = [fg_ansi] + else: + exclude = [] + code, name = _16_bg_colors.get_code(rgb, exclude=exclude) + return [code] + else: # Foreground. + code, name = _16_fg_colors.get_code(rgb) + fg_ansi = name + return [code] + + # True colors. (Only when this feature is enabled.) + elif self.color_depth == ColorDepth.DEPTH_24_BIT: + r, g, b = rgb + return [(48 if bg else 38), 2, r, g, b] + + # 256 RGB colors. + else: + return [(48 if bg else 38), 5, _256_colors[rgb]] + + result: list[int] = [] + result.extend(get(fg_color, False)) + result.extend(get(bg_color, True)) + + return map(str, result) + + +def _get_size(fileno: int) -> tuple[int, int]: + """ + Get the size of this pseudo terminal. + + :param fileno: stdout.fileno() + :returns: A (rows, cols) tuple. + """ + size = os.get_terminal_size(fileno) + return size.lines, size.columns + + +class Vt100_Output(Output): + """ + :param get_size: A callable which returns the `Size` of the output terminal. + :param stdout: Any object with has a `write` and `flush` method + an 'encoding' property. + :param term: The terminal environment variable. (xterm, xterm-256color, linux, ...) + :param enable_cpr: When `True` (the default), send "cursor position + request" escape sequences to the output in order to detect the cursor + position. That way, we can properly determine how much space there is + available for the UI (especially for drop down menus) to render. The + `Renderer` will still try to figure out whether the current terminal + does respond to CPR escapes. When `False`, never attempt to send CPR + requests. + """ + + # For the error messages. Only display "Output is not a terminal" once per + # file descriptor. + _fds_not_a_terminal: set[int] = set() + + def __init__( + self, + stdout: TextIO, + get_size: Callable[[], Size], + term: str | None = None, + default_color_depth: ColorDepth | None = None, + enable_bell: bool = True, + enable_cpr: bool = True, + ) -> None: + assert all(hasattr(stdout, a) for a in ("write", "flush")) + + self._buffer: list[str] = [] + self.stdout: TextIO = stdout + self.default_color_depth = default_color_depth + self._get_size = get_size + self.term = term + self.enable_bell = enable_bell + self.enable_cpr = enable_cpr + + # Cache for escape codes. + self._escape_code_caches: dict[ColorDepth, _EscapeCodeCache] = { + ColorDepth.DEPTH_1_BIT: _EscapeCodeCache(ColorDepth.DEPTH_1_BIT), + ColorDepth.DEPTH_4_BIT: _EscapeCodeCache(ColorDepth.DEPTH_4_BIT), + ColorDepth.DEPTH_8_BIT: _EscapeCodeCache(ColorDepth.DEPTH_8_BIT), + ColorDepth.DEPTH_24_BIT: _EscapeCodeCache(ColorDepth.DEPTH_24_BIT), + } + + # Keep track of whether the cursor shape was ever changed. + # (We don't restore the cursor shape if it was never changed - by + # default, we don't change them.) + self._cursor_shape_changed = False + + @classmethod + def from_pty( + cls, + stdout: TextIO, + term: str | None = None, + default_color_depth: ColorDepth | None = None, + enable_bell: bool = True, + ) -> Vt100_Output: + """ + Create an Output class from a pseudo terminal. + (This will take the dimensions by reading the pseudo + terminal attributes.) + """ + fd: int | None + # Normally, this requires a real TTY device, but people instantiate + # this class often during unit tests as well. For convenience, we print + # an error message, use standard dimensions, and go on. + try: + fd = stdout.fileno() + except io.UnsupportedOperation: + fd = None + + if not stdout.isatty() and (fd is None or fd not in cls._fds_not_a_terminal): + msg = "Warning: Output is not a terminal (fd=%r).\n" + sys.stderr.write(msg % fd) + sys.stderr.flush() + if fd is not None: + cls._fds_not_a_terminal.add(fd) + + def get_size() -> Size: + # If terminal (incorrectly) reports its size as 0, pick a + # reasonable default. See + # https://github.com/ipython/ipython/issues/10071 + rows, columns = (None, None) + + # It is possible that `stdout` is no longer a TTY device at this + # point. In that case we get an `OSError` in the ioctl call in + # `get_size`. See: + # https://github.com/prompt-toolkit/python-prompt-toolkit/pull/1021 + try: + rows, columns = _get_size(stdout.fileno()) + except OSError: + pass + return Size(rows=rows or 24, columns=columns or 80) + + return cls( + stdout, + get_size, + term=term, + default_color_depth=default_color_depth, + enable_bell=enable_bell, + ) + + def get_size(self) -> Size: + return self._get_size() + + def fileno(self) -> int: + "Return file descriptor." + return self.stdout.fileno() + + def encoding(self) -> str: + "Return encoding used for stdout." + return self.stdout.encoding + + def write_raw(self, data: str) -> None: + """ + Write raw data to output. + """ + self._buffer.append(data) + + def write(self, data: str) -> None: + """ + Write text to output. + (Removes vt100 escape codes. -- used for safely writing text.) + """ + self._buffer.append(data.replace("\x1b", "?")) + + def set_title(self, title: str) -> None: + """ + Set terminal title. + """ + if self.term not in ( + "linux", + "eterm-color", + ): # Not supported by the Linux console. + self.write_raw( + "\x1b]2;%s\x07" % title.replace("\x1b", "").replace("\x07", "") + ) + + def clear_title(self) -> None: + self.set_title("") + + def erase_screen(self) -> None: + """ + Erases the screen with the background color and moves the cursor to + home. + """ + self.write_raw("\x1b[2J") + + def enter_alternate_screen(self) -> None: + self.write_raw("\x1b[?1049h\x1b[H") + + def quit_alternate_screen(self) -> None: + self.write_raw("\x1b[?1049l") + + def enable_mouse_support(self) -> None: + self.write_raw("\x1b[?1000h") + + # Enable mouse-drag support. + self.write_raw("\x1b[?1003h") + + # Enable urxvt Mouse mode. (For terminals that understand this.) + self.write_raw("\x1b[?1015h") + + # Also enable Xterm SGR mouse mode. (For terminals that understand this.) + self.write_raw("\x1b[?1006h") + + # Note: E.g. lxterminal understands 1000h, but not the urxvt or sgr + # extensions. + + def disable_mouse_support(self) -> None: + self.write_raw("\x1b[?1000l") + self.write_raw("\x1b[?1015l") + self.write_raw("\x1b[?1006l") + self.write_raw("\x1b[?1003l") + + def erase_end_of_line(self) -> None: + """ + Erases from the current cursor position to the end of the current line. + """ + self.write_raw("\x1b[K") + + def erase_down(self) -> None: + """ + Erases the screen from the current line down to the bottom of the + screen. + """ + self.write_raw("\x1b[J") + + def reset_attributes(self) -> None: + self.write_raw("\x1b[0m") + + def set_attributes(self, attrs: Attrs, color_depth: ColorDepth) -> None: + """ + Create new style and output. + + :param attrs: `Attrs` instance. + """ + # Get current depth. + escape_code_cache = self._escape_code_caches[color_depth] + + # Write escape character. + self.write_raw(escape_code_cache[attrs]) + + def disable_autowrap(self) -> None: + self.write_raw("\x1b[?7l") + + def enable_autowrap(self) -> None: + self.write_raw("\x1b[?7h") + + def enable_bracketed_paste(self) -> None: + self.write_raw("\x1b[?2004h") + + def disable_bracketed_paste(self) -> None: + self.write_raw("\x1b[?2004l") + + def reset_cursor_key_mode(self) -> None: + """ + For vt100 only. + Put the terminal in cursor mode (instead of application mode). + """ + # Put the terminal in cursor mode. (Instead of application mode.) + self.write_raw("\x1b[?1l") + + def cursor_goto(self, row: int = 0, column: int = 0) -> None: + """ + Move cursor position. + """ + self.write_raw("\x1b[%i;%iH" % (row, column)) + + def cursor_up(self, amount: int) -> None: + if amount == 0: + pass + elif amount == 1: + self.write_raw("\x1b[A") + else: + self.write_raw("\x1b[%iA" % amount) + + def cursor_down(self, amount: int) -> None: + if amount == 0: + pass + elif amount == 1: + # Note: Not the same as '\n', '\n' can cause the window content to + # scroll. + self.write_raw("\x1b[B") + else: + self.write_raw("\x1b[%iB" % amount) + + def cursor_forward(self, amount: int) -> None: + if amount == 0: + pass + elif amount == 1: + self.write_raw("\x1b[C") + else: + self.write_raw("\x1b[%iC" % amount) + + def cursor_backward(self, amount: int) -> None: + if amount == 0: + pass + elif amount == 1: + self.write_raw("\b") # '\x1b[D' + else: + self.write_raw("\x1b[%iD" % amount) + + def hide_cursor(self) -> None: + self.write_raw("\x1b[?25l") + + def show_cursor(self) -> None: + 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: + return + + self._cursor_shape_changed = True + self.write_raw( + { + CursorShape.BLOCK: "\x1b[2 q", + CursorShape.BEAM: "\x1b[6 q", + CursorShape.UNDERLINE: "\x1b[4 q", + CursorShape.BLINKING_BLOCK: "\x1b[1 q", + CursorShape.BLINKING_BEAM: "\x1b[5 q", + CursorShape.BLINKING_UNDERLINE: "\x1b[3 q", + }.get(cursor_shape, "") + ) + + def reset_cursor_shape(self) -> None: + "Reset cursor shape." + # (Only reset cursor shape, if we ever changed it.) + if self._cursor_shape_changed: + self._cursor_shape_changed = False + + # Reset cursor shape. + self.write_raw("\x1b[0 q") + + def flush(self) -> None: + """ + Write to output stream and flush. + """ + if not self._buffer: + return + + data = "".join(self._buffer) + self._buffer = [] + + flush_stdout(self.stdout, data) + + def ask_for_cpr(self) -> None: + """ + Asks for a cursor position report (CPR). + """ + self.write_raw("\x1b[6n") + self.flush() + + @property + def responds_to_cpr(self) -> bool: + if not self.enable_cpr: + return False + + # When the input is a tty, we assume that CPR is supported. + # It's not when the input is piped from Pexpect. + if os.environ.get("PROMPT_TOOLKIT_NO_CPR", "") == "1": + return False + + if is_dumb_terminal(self.term): + return False + try: + return self.stdout.isatty() + except ValueError: + return False # ValueError: I/O operation on closed file + + def bell(self) -> None: + "Sound bell." + if self.enable_bell: + self.write_raw("\a") + self.flush() + + def get_default_color_depth(self) -> ColorDepth: + """ + Return the default color depth for a vt100 terminal, according to the + our term value. + + We prefer 256 colors almost always, because this is what most terminals + support these days, and is a good default. + """ + if self.default_color_depth is not None: + return self.default_color_depth + + term = self.term + + if term is None: + return ColorDepth.DEFAULT + + if is_dumb_terminal(term): + return ColorDepth.DEPTH_1_BIT + + if term in ("linux", "eterm-color"): + return ColorDepth.DEPTH_4_BIT + + return ColorDepth.DEFAULT diff --git a/src/prompt_toolkit/output/win32.py b/src/prompt_toolkit/output/win32.py new file mode 100644 index 0000000..edeca09 --- /dev/null +++ b/src/prompt_toolkit/output/win32.py @@ -0,0 +1,683 @@ +from __future__ import annotations + +import sys + +assert sys.platform == "win32" + +import os +from ctypes import ArgumentError, byref, c_char, c_long, c_uint, c_ulong, pointer +from ctypes.wintypes import DWORD, HANDLE +from typing import Callable, TextIO, TypeVar + +from prompt_toolkit.cursor_shapes import CursorShape +from prompt_toolkit.data_structures import Size +from prompt_toolkit.styles import ANSI_COLOR_NAMES, Attrs +from prompt_toolkit.utils import get_cwidth +from prompt_toolkit.win32_types import ( + CONSOLE_SCREEN_BUFFER_INFO, + COORD, + SMALL_RECT, + STD_INPUT_HANDLE, + STD_OUTPUT_HANDLE, +) + +from ..utils import SPHINX_AUTODOC_RUNNING +from .base import Output +from .color_depth import ColorDepth + +# Do not import win32-specific stuff when generating documentation. +# Otherwise RTD would be unable to generate docs for this module. +if not SPHINX_AUTODOC_RUNNING: + from ctypes import windll + + +__all__ = [ + "Win32Output", +] + + +def _coord_byval(coord: COORD) -> c_long: + """ + Turns a COORD object into a c_long. + This will cause it to be passed by value instead of by reference. (That is what I think at least.) + + When running ``ptipython`` is run (only with IPython), we often got the following error:: + + Error in 'SetConsoleCursorPosition'. + ArgumentError("argument 2: <class 'TypeError'>: wrong type",) + argument 2: <class 'TypeError'>: wrong type + + It was solved by turning ``COORD`` parameters into a ``c_long`` like this. + + More info: http://msdn.microsoft.com/en-us/library/windows/desktop/ms686025(v=vs.85).aspx + """ + return c_long(coord.Y * 0x10000 | coord.X & 0xFFFF) + + +#: If True: write the output of the renderer also to the following file. This +#: is very useful for debugging. (e.g.: to see that we don't write more bytes +#: than required.) +_DEBUG_RENDER_OUTPUT = False +_DEBUG_RENDER_OUTPUT_FILENAME = r"prompt-toolkit-windows-output.log" + + +class NoConsoleScreenBufferError(Exception): + """ + Raised when the application is not running inside a Windows Console, but + the user tries to instantiate Win32Output. + """ + + def __init__(self) -> None: + # Are we running in 'xterm' on Windows, like git-bash for instance? + xterm = "xterm" in os.environ.get("TERM", "") + + if xterm: + message = ( + "Found %s, while expecting a Windows console. " + 'Maybe try to run this program using "winpty" ' + "or run it in cmd.exe instead. Or otherwise, " + "in case of Cygwin, use the Python executable " + "that is compiled for Cygwin." % os.environ["TERM"] + ) + else: + message = "No Windows console found. Are you running cmd.exe?" + super().__init__(message) + + +_T = TypeVar("_T") + + +class Win32Output(Output): + """ + I/O abstraction for rendering to Windows consoles. + (cmd.exe and similar.) + """ + + def __init__( + self, + stdout: TextIO, + use_complete_width: bool = False, + default_color_depth: ColorDepth | None = None, + ) -> None: + self.use_complete_width = use_complete_width + self.default_color_depth = default_color_depth + + self._buffer: list[str] = [] + self.stdout: TextIO = stdout + self.hconsole = HANDLE(windll.kernel32.GetStdHandle(STD_OUTPUT_HANDLE)) + + self._in_alternate_screen = False + self._hidden = False + + self.color_lookup_table = ColorLookupTable() + + # Remember the default console colors. + info = self.get_win32_screen_buffer_info() + self.default_attrs = info.wAttributes if info else 15 + + if _DEBUG_RENDER_OUTPUT: + self.LOG = open(_DEBUG_RENDER_OUTPUT_FILENAME, "ab") + + def fileno(self) -> int: + "Return file descriptor." + return self.stdout.fileno() + + def encoding(self) -> str: + "Return encoding used for stdout." + return self.stdout.encoding + + def write(self, data: str) -> None: + if self._hidden: + data = " " * get_cwidth(data) + + self._buffer.append(data) + + def write_raw(self, data: str) -> None: + "For win32, there is no difference between write and write_raw." + self.write(data) + + def get_size(self) -> Size: + info = self.get_win32_screen_buffer_info() + + # We take the width of the *visible* region as the size. Not the width + # of the complete screen buffer. (Unless use_complete_width has been + # set.) + if self.use_complete_width: + width = info.dwSize.X + else: + width = info.srWindow.Right - info.srWindow.Left + + height = info.srWindow.Bottom - info.srWindow.Top + 1 + + # We avoid the right margin, windows will wrap otherwise. + maxwidth = info.dwSize.X - 1 + width = min(maxwidth, width) + + # Create `Size` object. + return Size(rows=height, columns=width) + + def _winapi(self, func: Callable[..., _T], *a: object, **kw: object) -> _T: + """ + Flush and call win API function. + """ + self.flush() + + if _DEBUG_RENDER_OUTPUT: + self.LOG.write(("%r" % func.__name__).encode("utf-8") + b"\n") + self.LOG.write( + b" " + ", ".join(["%r" % i for i in a]).encode("utf-8") + b"\n" + ) + self.LOG.write( + b" " + + ", ".join(["%r" % type(i) for i in a]).encode("utf-8") + + b"\n" + ) + self.LOG.flush() + + try: + return func(*a, **kw) + except ArgumentError as e: + if _DEBUG_RENDER_OUTPUT: + self.LOG.write((f" Error in {func.__name__!r} {e!r} {e}\n").encode()) + + raise + + def get_win32_screen_buffer_info(self) -> CONSOLE_SCREEN_BUFFER_INFO: + """ + Return Screen buffer info. + """ + # NOTE: We don't call the `GetConsoleScreenBufferInfo` API through + # `self._winapi`. Doing so causes Python to crash on certain 64bit + # Python versions. (Reproduced with 64bit Python 2.7.6, on Windows + # 10). It is not clear why. Possibly, it has to do with passing + # these objects as an argument, or through *args. + + # The Python documentation contains the following - possibly related - warning: + # ctypes does not support passing unions or structures with + # bit-fields to functions by value. While this may work on 32-bit + # x86, it's not guaranteed by the library to work in the general + # case. Unions and structures with bit-fields should always be + # passed to functions by pointer. + + # Also see: + # - https://github.com/ipython/ipython/issues/10070 + # - https://github.com/jonathanslenders/python-prompt-toolkit/issues/406 + # - https://github.com/jonathanslenders/python-prompt-toolkit/issues/86 + + self.flush() + sbinfo = CONSOLE_SCREEN_BUFFER_INFO() + success = windll.kernel32.GetConsoleScreenBufferInfo( + self.hconsole, byref(sbinfo) + ) + + # success = self._winapi(windll.kernel32.GetConsoleScreenBufferInfo, + # self.hconsole, byref(sbinfo)) + + if success: + return sbinfo + else: + raise NoConsoleScreenBufferError + + def set_title(self, title: str) -> None: + """ + Set terminal title. + """ + self._winapi(windll.kernel32.SetConsoleTitleW, title) + + def clear_title(self) -> None: + self._winapi(windll.kernel32.SetConsoleTitleW, "") + + def erase_screen(self) -> None: + start = COORD(0, 0) + sbinfo = self.get_win32_screen_buffer_info() + length = sbinfo.dwSize.X * sbinfo.dwSize.Y + + self.cursor_goto(row=0, column=0) + self._erase(start, length) + + def erase_down(self) -> None: + sbinfo = self.get_win32_screen_buffer_info() + size = sbinfo.dwSize + + start = sbinfo.dwCursorPosition + length = (size.X - size.X) + size.X * (size.Y - sbinfo.dwCursorPosition.Y) + + self._erase(start, length) + + def erase_end_of_line(self) -> None: + """""" + sbinfo = self.get_win32_screen_buffer_info() + start = sbinfo.dwCursorPosition + length = sbinfo.dwSize.X - sbinfo.dwCursorPosition.X + + self._erase(start, length) + + def _erase(self, start: COORD, length: int) -> None: + chars_written = c_ulong() + + self._winapi( + windll.kernel32.FillConsoleOutputCharacterA, + self.hconsole, + c_char(b" "), + DWORD(length), + _coord_byval(start), + byref(chars_written), + ) + + # Reset attributes. + sbinfo = self.get_win32_screen_buffer_info() + self._winapi( + windll.kernel32.FillConsoleOutputAttribute, + self.hconsole, + sbinfo.wAttributes, + length, + _coord_byval(start), + byref(chars_written), + ) + + def reset_attributes(self) -> None: + "Reset the console foreground/background color." + self._winapi( + windll.kernel32.SetConsoleTextAttribute, self.hconsole, self.default_attrs + ) + self._hidden = False + + def set_attributes(self, attrs: Attrs, color_depth: ColorDepth) -> None: + ( + fgcolor, + bgcolor, + bold, + underline, + strike, + italic, + blink, + reverse, + hidden, + ) = attrs + self._hidden = bool(hidden) + + # Start from the default attributes. + win_attrs: int = self.default_attrs + + if color_depth != ColorDepth.DEPTH_1_BIT: + # Override the last four bits: foreground color. + if fgcolor: + win_attrs = win_attrs & ~0xF + win_attrs |= self.color_lookup_table.lookup_fg_color(fgcolor) + + # Override the next four bits: background color. + if bgcolor: + win_attrs = win_attrs & ~0xF0 + win_attrs |= self.color_lookup_table.lookup_bg_color(bgcolor) + + # Reverse: swap these four bits groups. + if reverse: + win_attrs = ( + (win_attrs & ~0xFF) + | ((win_attrs & 0xF) << 4) + | ((win_attrs & 0xF0) >> 4) + ) + + self._winapi(windll.kernel32.SetConsoleTextAttribute, self.hconsole, win_attrs) + + def disable_autowrap(self) -> None: + # Not supported by Windows. + pass + + def enable_autowrap(self) -> None: + # Not supported by Windows. + pass + + def cursor_goto(self, row: int = 0, column: int = 0) -> None: + pos = COORD(X=column, Y=row) + self._winapi( + windll.kernel32.SetConsoleCursorPosition, self.hconsole, _coord_byval(pos) + ) + + def cursor_up(self, amount: int) -> None: + sr = self.get_win32_screen_buffer_info().dwCursorPosition + pos = COORD(X=sr.X, Y=sr.Y - amount) + self._winapi( + windll.kernel32.SetConsoleCursorPosition, self.hconsole, _coord_byval(pos) + ) + + def cursor_down(self, amount: int) -> None: + self.cursor_up(-amount) + + def cursor_forward(self, amount: int) -> None: + sr = self.get_win32_screen_buffer_info().dwCursorPosition + # assert sr.X + amount >= 0, 'Negative cursor position: x=%r amount=%r' % (sr.X, amount) + + pos = COORD(X=max(0, sr.X + amount), Y=sr.Y) + self._winapi( + windll.kernel32.SetConsoleCursorPosition, self.hconsole, _coord_byval(pos) + ) + + def cursor_backward(self, amount: int) -> None: + self.cursor_forward(-amount) + + def flush(self) -> None: + """ + Write to output stream and flush. + """ + if not self._buffer: + # Only flush stdout buffer. (It could be that Python still has + # something in its buffer. -- We want to be sure to print that in + # the correct color.) + self.stdout.flush() + return + + data = "".join(self._buffer) + + if _DEBUG_RENDER_OUTPUT: + self.LOG.write(("%r" % data).encode("utf-8") + b"\n") + self.LOG.flush() + + # Print characters one by one. This appears to be the best solution + # in order to avoid traces of vertical lines when the completion + # menu disappears. + for b in data: + written = DWORD() + + retval = windll.kernel32.WriteConsoleW( + self.hconsole, b, 1, byref(written), None + ) + assert retval != 0 + + self._buffer = [] + + def get_rows_below_cursor_position(self) -> int: + info = self.get_win32_screen_buffer_info() + return info.srWindow.Bottom - info.dwCursorPosition.Y + 1 + + def scroll_buffer_to_prompt(self) -> None: + """ + To be called before drawing the prompt. This should scroll the console + to left, with the cursor at the bottom (if possible). + """ + # Get current window size + info = self.get_win32_screen_buffer_info() + sr = info.srWindow + cursor_pos = info.dwCursorPosition + + result = SMALL_RECT() + + # Scroll to the left. + result.Left = 0 + result.Right = sr.Right - sr.Left + + # Scroll vertical + win_height = sr.Bottom - sr.Top + if 0 < sr.Bottom - cursor_pos.Y < win_height - 1: + # no vertical scroll if cursor already on the screen + result.Bottom = sr.Bottom + else: + result.Bottom = max(win_height, cursor_pos.Y) + result.Top = result.Bottom - win_height + + # Scroll API + self._winapi( + windll.kernel32.SetConsoleWindowInfo, self.hconsole, True, byref(result) + ) + + def enter_alternate_screen(self) -> None: + """ + Go to alternate screen buffer. + """ + if not self._in_alternate_screen: + GENERIC_READ = 0x80000000 + GENERIC_WRITE = 0x40000000 + + # Create a new console buffer and activate that one. + handle = HANDLE( + self._winapi( + windll.kernel32.CreateConsoleScreenBuffer, + GENERIC_READ | GENERIC_WRITE, + DWORD(0), + None, + DWORD(1), + None, + ) + ) + + self._winapi(windll.kernel32.SetConsoleActiveScreenBuffer, handle) + self.hconsole = handle + self._in_alternate_screen = True + + def quit_alternate_screen(self) -> None: + """ + Make stdout again the active buffer. + """ + if self._in_alternate_screen: + stdout = HANDLE( + self._winapi(windll.kernel32.GetStdHandle, STD_OUTPUT_HANDLE) + ) + self._winapi(windll.kernel32.SetConsoleActiveScreenBuffer, stdout) + self._winapi(windll.kernel32.CloseHandle, self.hconsole) + self.hconsole = stdout + self._in_alternate_screen = False + + def enable_mouse_support(self) -> None: + ENABLE_MOUSE_INPUT = 0x10 + + # This `ENABLE_QUICK_EDIT_MODE` flag needs to be cleared for mouse + # support to work, but it's possible that it was already cleared + # before. + ENABLE_QUICK_EDIT_MODE = 0x0040 + + handle = HANDLE(windll.kernel32.GetStdHandle(STD_INPUT_HANDLE)) + + original_mode = DWORD() + self._winapi(windll.kernel32.GetConsoleMode, handle, pointer(original_mode)) + self._winapi( + windll.kernel32.SetConsoleMode, + handle, + (original_mode.value | ENABLE_MOUSE_INPUT) & ~ENABLE_QUICK_EDIT_MODE, + ) + + def disable_mouse_support(self) -> None: + ENABLE_MOUSE_INPUT = 0x10 + handle = HANDLE(windll.kernel32.GetStdHandle(STD_INPUT_HANDLE)) + + original_mode = DWORD() + self._winapi(windll.kernel32.GetConsoleMode, handle, pointer(original_mode)) + self._winapi( + windll.kernel32.SetConsoleMode, + handle, + original_mode.value & ~ENABLE_MOUSE_INPUT, + ) + + def hide_cursor(self) -> None: + pass + + def show_cursor(self) -> None: + pass + + def set_cursor_shape(self, cursor_shape: CursorShape) -> None: + pass + + def reset_cursor_shape(self) -> None: + pass + + @classmethod + def win32_refresh_window(cls) -> None: + """ + Call win32 API to refresh the whole Window. + + This is sometimes necessary when the application paints background + for completion menus. When the menu disappears, it leaves traces due + to a bug in the Windows Console. Sending a repaint request solves it. + """ + # Get console handle + handle = HANDLE(windll.kernel32.GetConsoleWindow()) + + RDW_INVALIDATE = 0x0001 + windll.user32.RedrawWindow(handle, None, None, c_uint(RDW_INVALIDATE)) + + def get_default_color_depth(self) -> ColorDepth: + """ + Return the default color depth for a windows terminal. + + Contrary to the Vt100 implementation, this doesn't depend on a $TERM + variable. + """ + if self.default_color_depth is not None: + return self.default_color_depth + + return ColorDepth.DEPTH_4_BIT + + +class FOREGROUND_COLOR: + BLACK = 0x0000 + BLUE = 0x0001 + GREEN = 0x0002 + CYAN = 0x0003 + RED = 0x0004 + MAGENTA = 0x0005 + YELLOW = 0x0006 + GRAY = 0x0007 + INTENSITY = 0x0008 # Foreground color is intensified. + + +class BACKGROUND_COLOR: + BLACK = 0x0000 + BLUE = 0x0010 + GREEN = 0x0020 + CYAN = 0x0030 + RED = 0x0040 + MAGENTA = 0x0050 + YELLOW = 0x0060 + GRAY = 0x0070 + INTENSITY = 0x0080 # Background color is intensified. + + +def _create_ansi_color_dict( + color_cls: type[FOREGROUND_COLOR] | type[BACKGROUND_COLOR], +) -> dict[str, int]: + "Create a table that maps the 16 named ansi colors to their Windows code." + return { + "ansidefault": color_cls.BLACK, + "ansiblack": color_cls.BLACK, + "ansigray": color_cls.GRAY, + "ansibrightblack": color_cls.BLACK | color_cls.INTENSITY, + "ansiwhite": color_cls.GRAY | color_cls.INTENSITY, + # Low intensity. + "ansired": color_cls.RED, + "ansigreen": color_cls.GREEN, + "ansiyellow": color_cls.YELLOW, + "ansiblue": color_cls.BLUE, + "ansimagenta": color_cls.MAGENTA, + "ansicyan": color_cls.CYAN, + # High intensity. + "ansibrightred": color_cls.RED | color_cls.INTENSITY, + "ansibrightgreen": color_cls.GREEN | color_cls.INTENSITY, + "ansibrightyellow": color_cls.YELLOW | color_cls.INTENSITY, + "ansibrightblue": color_cls.BLUE | color_cls.INTENSITY, + "ansibrightmagenta": color_cls.MAGENTA | color_cls.INTENSITY, + "ansibrightcyan": color_cls.CYAN | color_cls.INTENSITY, + } + + +FG_ANSI_COLORS = _create_ansi_color_dict(FOREGROUND_COLOR) +BG_ANSI_COLORS = _create_ansi_color_dict(BACKGROUND_COLOR) + +assert set(FG_ANSI_COLORS) == set(ANSI_COLOR_NAMES) +assert set(BG_ANSI_COLORS) == set(ANSI_COLOR_NAMES) + + +class ColorLookupTable: + """ + Inspired by pygments/formatters/terminal256.py + """ + + def __init__(self) -> None: + self._win32_colors = self._build_color_table() + + # Cache (map color string to foreground and background code). + self.best_match: dict[str, tuple[int, int]] = {} + + @staticmethod + def _build_color_table() -> list[tuple[int, int, int, int, int]]: + """ + Build an RGB-to-256 color conversion table + """ + FG = FOREGROUND_COLOR + BG = BACKGROUND_COLOR + + return [ + (0x00, 0x00, 0x00, FG.BLACK, BG.BLACK), + (0x00, 0x00, 0xAA, FG.BLUE, BG.BLUE), + (0x00, 0xAA, 0x00, FG.GREEN, BG.GREEN), + (0x00, 0xAA, 0xAA, FG.CYAN, BG.CYAN), + (0xAA, 0x00, 0x00, FG.RED, BG.RED), + (0xAA, 0x00, 0xAA, FG.MAGENTA, BG.MAGENTA), + (0xAA, 0xAA, 0x00, FG.YELLOW, BG.YELLOW), + (0x88, 0x88, 0x88, FG.GRAY, BG.GRAY), + (0x44, 0x44, 0xFF, FG.BLUE | FG.INTENSITY, BG.BLUE | BG.INTENSITY), + (0x44, 0xFF, 0x44, FG.GREEN | FG.INTENSITY, BG.GREEN | BG.INTENSITY), + (0x44, 0xFF, 0xFF, FG.CYAN | FG.INTENSITY, BG.CYAN | BG.INTENSITY), + (0xFF, 0x44, 0x44, FG.RED | FG.INTENSITY, BG.RED | BG.INTENSITY), + (0xFF, 0x44, 0xFF, FG.MAGENTA | FG.INTENSITY, BG.MAGENTA | BG.INTENSITY), + (0xFF, 0xFF, 0x44, FG.YELLOW | FG.INTENSITY, BG.YELLOW | BG.INTENSITY), + (0x44, 0x44, 0x44, FG.BLACK | FG.INTENSITY, BG.BLACK | BG.INTENSITY), + (0xFF, 0xFF, 0xFF, FG.GRAY | FG.INTENSITY, BG.GRAY | BG.INTENSITY), + ] + + def _closest_color(self, r: int, g: int, b: int) -> tuple[int, int]: + distance = 257 * 257 * 3 # "infinity" (>distance from #000000 to #ffffff) + fg_match = 0 + bg_match = 0 + + for r_, g_, b_, fg_, bg_ in self._win32_colors: + rd = r - r_ + gd = g - g_ + bd = b - b_ + + d = rd * rd + gd * gd + bd * bd + + if d < distance: + fg_match = fg_ + bg_match = bg_ + distance = d + return fg_match, bg_match + + def _color_indexes(self, color: str) -> tuple[int, int]: + indexes = self.best_match.get(color, None) + if indexes is None: + try: + rgb = int(str(color), 16) + except ValueError: + rgb = 0 + + r = (rgb >> 16) & 0xFF + g = (rgb >> 8) & 0xFF + b = rgb & 0xFF + indexes = self._closest_color(r, g, b) + self.best_match[color] = indexes + return indexes + + def lookup_fg_color(self, fg_color: str) -> int: + """ + Return the color for use in the + `windll.kernel32.SetConsoleTextAttribute` API call. + + :param fg_color: Foreground as text. E.g. 'ffffff' or 'red' + """ + # Foreground. + if fg_color in FG_ANSI_COLORS: + return FG_ANSI_COLORS[fg_color] + else: + return self._color_indexes(fg_color)[0] + + def lookup_bg_color(self, bg_color: str) -> int: + """ + Return the color for use in the + `windll.kernel32.SetConsoleTextAttribute` API call. + + :param bg_color: Background as text. E.g. 'ffffff' or 'red' + """ + # Background. + if bg_color in BG_ANSI_COLORS: + return BG_ANSI_COLORS[bg_color] + else: + return self._color_indexes(bg_color)[1] diff --git a/src/prompt_toolkit/output/windows10.py b/src/prompt_toolkit/output/windows10.py new file mode 100644 index 0000000..c39f3ec --- /dev/null +++ b/src/prompt_toolkit/output/windows10.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import sys + +assert sys.platform == "win32" + +from ctypes import byref, windll +from ctypes.wintypes import DWORD, HANDLE +from typing import Any, TextIO + +from prompt_toolkit.data_structures import Size +from prompt_toolkit.win32_types import STD_OUTPUT_HANDLE + +from .base import Output +from .color_depth import ColorDepth +from .vt100 import Vt100_Output +from .win32 import Win32Output + +__all__ = [ + "Windows10_Output", +] + +# See: https://msdn.microsoft.com/pl-pl/library/windows/desktop/ms686033(v=vs.85).aspx +ENABLE_PROCESSED_INPUT = 0x0001 +ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 + + +class Windows10_Output: + """ + Windows 10 output abstraction. This enables and uses vt100 escape sequences. + """ + + def __init__( + self, stdout: TextIO, default_color_depth: ColorDepth | None = None + ) -> None: + self.default_color_depth = default_color_depth + self.win32_output = Win32Output(stdout, default_color_depth=default_color_depth) + self.vt100_output = Vt100_Output( + stdout, lambda: Size(0, 0), default_color_depth=default_color_depth + ) + self._hconsole = HANDLE(windll.kernel32.GetStdHandle(STD_OUTPUT_HANDLE)) + + def flush(self) -> None: + """ + Write to output stream and flush. + """ + original_mode = DWORD(0) + + # Remember the previous console mode. + windll.kernel32.GetConsoleMode(self._hconsole, byref(original_mode)) + + # Enable processing of vt100 sequences. + windll.kernel32.SetConsoleMode( + self._hconsole, + DWORD(ENABLE_PROCESSED_INPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING), + ) + + try: + self.vt100_output.flush() + finally: + # Restore console mode. + windll.kernel32.SetConsoleMode(self._hconsole, original_mode) + + @property + def responds_to_cpr(self) -> bool: + return False # We don't need this on Windows. + + def __getattr__(self, name: str) -> Any: + 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", + ): + return getattr(self.win32_output, name) + else: + return getattr(self.vt100_output, name) + + def get_default_color_depth(self) -> ColorDepth: + """ + Return the default color depth for a windows terminal. + + Contrary to the Vt100 implementation, this doesn't depend on a $TERM + variable. + """ + if self.default_color_depth is not None: + return self.default_color_depth + + # Previously, we used `DEPTH_4_BIT`, even on Windows 10. This was + # because true color support was added after "Console Virtual Terminal + # Sequences" support was added, and there was no good way to detect + # what support was given. + # 24bit color support was added in 2016, so let's assume it's safe to + # take that as a default: + # https://devblogs.microsoft.com/commandline/24-bit-color-in-the-windows-console/ + return ColorDepth.TRUE_COLOR + + +Output.register(Windows10_Output) + + +def is_win_vt100_enabled() -> bool: + """ + Returns True when we're running Windows and VT100 escape sequences are + supported. + """ + if sys.platform != "win32": + return False + + hconsole = HANDLE(windll.kernel32.GetStdHandle(STD_OUTPUT_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_PROCESSED_INPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING) + ) + + return result == 1 + finally: + windll.kernel32.SetConsoleMode(hconsole, original_mode) diff --git a/src/prompt_toolkit/patch_stdout.py b/src/prompt_toolkit/patch_stdout.py new file mode 100644 index 0000000..528bec7 --- /dev/null +++ b/src/prompt_toolkit/patch_stdout.py @@ -0,0 +1,296 @@ +""" +patch_stdout +============ + +This implements a context manager that ensures that print statements within +it won't destroy the user interface. The context manager will replace +`sys.stdout` by something that draws the output above the current prompt, +rather than overwriting the UI. + +Usage:: + + with patch_stdout(application): + ... + application.run() + ... + +Multiple applications can run in the body of the context manager, one after the +other. +""" +from __future__ import annotations + +import asyncio +import queue +import sys +import threading +import time +from contextlib import contextmanager +from typing import Generator, TextIO, cast + +from .application import get_app_session, run_in_terminal +from .output import Output + +__all__ = [ + "patch_stdout", + "StdoutProxy", +] + + +@contextmanager +def patch_stdout(raw: bool = False) -> Generator[None, None, None]: + """ + Replace `sys.stdout` by an :class:`_StdoutProxy` instance. + + Writing to this proxy will make sure that the text appears above the + prompt, and that it doesn't destroy the output from the renderer. If no + application is curring, the behavior should be identical to writing to + `sys.stdout` directly. + + Warning: If a new event loop is installed using `asyncio.set_event_loop()`, + then make sure that the context manager is applied after the event loop + is changed. Printing to stdout will be scheduled in the event loop + that's active when the context manager is created. + + :param raw: (`bool`) When True, vt100 terminal escape sequences are not + removed/escaped. + """ + with StdoutProxy(raw=raw) as proxy: + original_stdout = sys.stdout + original_stderr = sys.stderr + + # Enter. + sys.stdout = cast(TextIO, proxy) + sys.stderr = cast(TextIO, proxy) + + try: + yield + finally: + sys.stdout = original_stdout + sys.stderr = original_stderr + + +class _Done: + "Sentinel value for stopping the stdout proxy." + + +class StdoutProxy: + """ + File-like object, which prints everything written to it, output above the + current application/prompt. This class is compatible with other file + objects and can be used as a drop-in replacement for `sys.stdout` or can + for instance be passed to `logging.StreamHandler`. + + The current application, above which we print, is determined by looking + what application currently runs in the `AppSession` that is active during + the creation of this instance. + + This class can be used as a context manager. + + In order to avoid having to repaint the prompt continuously for every + little write, a short delay of `sleep_between_writes` seconds will be added + between writes in order to bundle many smaller writes in a short timespan. + """ + + def __init__( + self, + sleep_between_writes: float = 0.2, + raw: bool = False, + ) -> None: + self.sleep_between_writes = sleep_between_writes + self.raw = raw + + self._lock = threading.RLock() + self._buffer: list[str] = [] + + # Keep track of the curret app session. + self.app_session = get_app_session() + + # See what output is active *right now*. We should do it at this point, + # before this `StdoutProxy` instance is possibly assigned to `sys.stdout`. + # Otherwise, if `patch_stdout` is used, and no `Output` instance has + # been created, then the default output creation code will see this + # proxy object as `sys.stdout`, and get in a recursive loop trying to + # access `StdoutProxy.isatty()` which will again retrieve the output. + self._output: Output = self.app_session.output + + # Flush thread + self._flush_queue: queue.Queue[str | _Done] = queue.Queue() + self._flush_thread = self._start_write_thread() + self.closed = False + + def __enter__(self) -> StdoutProxy: + return self + + def __exit__(self, *args: object) -> None: + self.close() + + def close(self) -> None: + """ + Stop `StdoutProxy` proxy. + + This will terminate the write thread, make sure everything is flushed + and wait for the write thread to finish. + """ + if not self.closed: + self._flush_queue.put(_Done()) + self._flush_thread.join() + self.closed = True + + def _start_write_thread(self) -> threading.Thread: + thread = threading.Thread( + target=self._write_thread, + name="patch-stdout-flush-thread", + daemon=True, + ) + thread.start() + return thread + + def _write_thread(self) -> None: + done = False + + while not done: + item = self._flush_queue.get() + + if isinstance(item, _Done): + break + + # Don't bother calling when we got an empty string. + if not item: + continue + + text = [] + text.append(item) + + # Read the rest of the queue if more data was queued up. + while True: + try: + item = self._flush_queue.get_nowait() + except queue.Empty: + break + else: + if isinstance(item, _Done): + done = True + else: + text.append(item) + + app_loop = self._get_app_loop() + self._write_and_flush(app_loop, "".join(text)) + + # If an application was running that requires repainting, then wait + # for a very short time, in order to bundle actual writes and avoid + # having to repaint to often. + if app_loop is not None: + time.sleep(self.sleep_between_writes) + + def _get_app_loop(self) -> asyncio.AbstractEventLoop | None: + """ + Return the event loop for the application currently running in our + `AppSession`. + """ + app = self.app_session.app + + if app is None: + return None + + return app.loop + + def _write_and_flush( + self, loop: asyncio.AbstractEventLoop | None, text: str + ) -> None: + """ + Write the given text to stdout and flush. + If an application is running, use `run_in_terminal`. + """ + + def write_and_flush() -> None: + # Ensure that autowrap is enabled before calling `write`. + # XXX: On Windows, the `Windows10_Output` enables/disables VT + # terminal processing for every flush. It turns out that this + # causes autowrap to be reset (disabled) after each flush. So, + # we have to enable it again before writing text. + self._output.enable_autowrap() + + if self.raw: + self._output.write_raw(text) + else: + self._output.write(text) + + self._output.flush() + + def write_and_flush_in_loop() -> None: + # If an application is running, use `run_in_terminal`, otherwise + # call it directly. + run_in_terminal(write_and_flush, in_executor=False) + + if loop is None: + # No loop, write immediately. + write_and_flush() + else: + # Make sure `write_and_flush` is executed *in* the event loop, not + # in another thread. + loop.call_soon_threadsafe(write_and_flush_in_loop) + + def _write(self, data: str) -> None: + """ + Note: print()-statements cause to multiple write calls. + (write('line') and write('\n')). Of course we don't want to call + `run_in_terminal` for every individual call, because that's too + expensive, and as long as the newline hasn't been written, the + text itself is again overwritten by the rendering of the input + command line. Therefor, we have a little buffer which holds the + text until a newline is written to stdout. + """ + if "\n" in data: + # When there is a newline in the data, write everything before the + # newline, including the newline itself. + before, after = data.rsplit("\n", 1) + to_write = self._buffer + [before, "\n"] + self._buffer = [after] + + text = "".join(to_write) + self._flush_queue.put(text) + else: + # Otherwise, cache in buffer. + self._buffer.append(data) + + def _flush(self) -> None: + text = "".join(self._buffer) + self._buffer = [] + self._flush_queue.put(text) + + def write(self, data: str) -> int: + with self._lock: + self._write(data) + + return len(data) # Pretend everything was written. + + def flush(self) -> None: + """ + Flush buffered output. + """ + with self._lock: + self._flush() + + @property + def original_stdout(self) -> TextIO: + return self._output.stdout or sys.__stdout__ + + # Attributes for compatibility with sys.__stdout__: + + def fileno(self) -> int: + return self._output.fileno() + + def isatty(self) -> bool: + stdout = self._output.stdout + if stdout is None: + return False + + return stdout.isatty() + + @property + def encoding(self) -> str: + return self._output.encoding() + + @property + def errors(self) -> str: + return "strict" diff --git a/src/prompt_toolkit/py.typed b/src/prompt_toolkit/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/prompt_toolkit/renderer.py b/src/prompt_toolkit/renderer.py new file mode 100644 index 0000000..5ad1dd6 --- /dev/null +++ b/src/prompt_toolkit/renderer.py @@ -0,0 +1,813 @@ +""" +Renders the command line on the console. +(Redraws parts of the input line that were changed.) +""" +from __future__ import annotations + +from asyncio import FIRST_COMPLETED, Future, ensure_future, sleep, wait +from collections import deque +from enum import Enum +from typing import TYPE_CHECKING, Any, Callable, Dict, Hashable + +from prompt_toolkit.application.current import get_app +from prompt_toolkit.cursor_shapes import CursorShape +from prompt_toolkit.data_structures import Point, Size +from prompt_toolkit.filters import FilterOrBool, to_filter +from prompt_toolkit.formatted_text import AnyFormattedText, to_formatted_text +from prompt_toolkit.layout.mouse_handlers import MouseHandlers +from prompt_toolkit.layout.screen import Char, Screen, WritePosition +from prompt_toolkit.output import ColorDepth, Output +from prompt_toolkit.styles import ( + Attrs, + BaseStyle, + DummyStyleTransformation, + StyleTransformation, +) + +if TYPE_CHECKING: + from prompt_toolkit.application import Application + from prompt_toolkit.layout.layout import Layout + + +__all__ = [ + "Renderer", + "print_formatted_text", +] + + +def _output_screen_diff( + app: Application[Any], + output: Output, + screen: Screen, + current_pos: Point, + color_depth: ColorDepth, + previous_screen: Screen | None, + last_style: str | None, + is_done: bool, # XXX: drop is_done + full_screen: bool, + attrs_for_style_string: _StyleStringToAttrsCache, + style_string_has_style: _StyleStringHasStyleCache, + size: Size, + previous_width: int, +) -> tuple[Point, str | None]: + """ + Render the diff between this screen and the previous screen. + + This takes two `Screen` instances. The one that represents the output like + it was during the last rendering and one that represents the current + output raster. Looking at these two `Screen` instances, this function will + render the difference by calling the appropriate methods of the `Output` + object that only paint the changes to the terminal. + + This is some performance-critical code which is heavily optimized. + Don't change things without profiling first. + + :param current_pos: Current cursor position. + :param last_style: The style string, used for drawing the last drawn + character. (Color/attributes.) + :param attrs_for_style_string: :class:`._StyleStringToAttrsCache` instance. + :param width: The width of the terminal. + :param previous_width: The width of the terminal during the last rendering. + """ + width, height = size.columns, size.rows + + #: Variable for capturing the output. + write = output.write + write_raw = output.write_raw + + # Create locals for the most used output methods. + # (Save expensive attribute lookups.) + _output_set_attributes = output.set_attributes + _output_reset_attributes = output.reset_attributes + _output_cursor_forward = output.cursor_forward + _output_cursor_up = output.cursor_up + _output_cursor_backward = output.cursor_backward + + # Hide cursor before rendering. (Avoid flickering.) + output.hide_cursor() + + def reset_attributes() -> None: + "Wrapper around Output.reset_attributes." + nonlocal last_style + _output_reset_attributes() + last_style = None # Forget last char after resetting attributes. + + def move_cursor(new: Point) -> Point: + "Move cursor to this `new` point. Returns the given Point." + current_x, current_y = current_pos.x, current_pos.y + + if new.y > current_y: + # Use newlines instead of CURSOR_DOWN, because this might add new lines. + # CURSOR_DOWN will never create new lines at the bottom. + # Also reset attributes, otherwise the newline could draw a + # background color. + reset_attributes() + write("\r\n" * (new.y - current_y)) + current_x = 0 + _output_cursor_forward(new.x) + return new + elif new.y < current_y: + _output_cursor_up(current_y - new.y) + + if current_x >= width - 1: + write("\r") + _output_cursor_forward(new.x) + elif new.x < current_x or current_x >= width - 1: + _output_cursor_backward(current_x - new.x) + elif new.x > current_x: + _output_cursor_forward(new.x - current_x) + + return new + + def output_char(char: Char) -> None: + """ + Write the output of this character. + """ + nonlocal last_style + + # If the last printed character has the same style, don't output the + # style again. + if last_style == char.style: + write(char.char) + else: + # Look up `Attr` for this style string. Only set attributes if different. + # (Two style strings can still have the same formatting.) + # Note that an empty style string can have formatting that needs to + # be applied, because of style transformations. + new_attrs = attrs_for_style_string[char.style] + if not last_style or new_attrs != attrs_for_style_string[last_style]: + _output_set_attributes(new_attrs, color_depth) + + write(char.char) + last_style = char.style + + def get_max_column_index(row: dict[int, Char]) -> int: + """ + Return max used column index, ignoring whitespace (without style) at + the end of the line. This is important for people that copy/paste + terminal output. + + There are two reasons we are sometimes seeing whitespace at the end: + - `BufferControl` adds a trailing space to each line, because it's a + possible cursor position, so that the line wrapping won't change if + the cursor position moves around. + - The `Window` adds a style class to the current line for highlighting + (cursor-line). + """ + numbers = ( + index + for index, cell in row.items() + if cell.char != " " or style_string_has_style[cell.style] + ) + return max(numbers, default=0) + + # Render for the first time: reset styling. + if not previous_screen: + reset_attributes() + + # Disable autowrap. (When entering a the alternate screen, or anytime when + # we have a prompt. - In the case of a REPL, like IPython, people can have + # background threads, and it's hard for debugging if their output is not + # wrapped.) + if not previous_screen or not full_screen: + output.disable_autowrap() + + # When the previous screen has a different size, redraw everything anyway. + # Also when we are done. (We might take up less rows, so clearing is important.) + if ( + is_done or not previous_screen or previous_width != width + ): # XXX: also consider height?? + current_pos = move_cursor(Point(x=0, y=0)) + reset_attributes() + output.erase_down() + + previous_screen = Screen() + + # Get height of the screen. + # (height changes as we loop over data_buffer, so remember the current value.) + # (Also make sure to clip the height to the size of the output.) + current_height = min(screen.height, height) + + # Loop over the rows. + row_count = min(max(screen.height, previous_screen.height), height) + + for y in range(row_count): + new_row = screen.data_buffer[y] + previous_row = previous_screen.data_buffer[y] + zero_width_escapes_row = screen.zero_width_escapes[y] + + new_max_line_len = min(width - 1, get_max_column_index(new_row)) + previous_max_line_len = min(width - 1, get_max_column_index(previous_row)) + + # Loop over the columns. + c = 0 # Column counter. + while c <= new_max_line_len: + new_char = new_row[c] + old_char = previous_row[c] + char_width = new_char.width or 1 + + # When the old and new character at this position are different, + # draw the output. (Because of the performance, we don't call + # `Char.__ne__`, but inline the same expression.) + if new_char.char != old_char.char or new_char.style != old_char.style: + current_pos = move_cursor(Point(x=c, y=y)) + + # Send injected escape sequences to output. + if c in zero_width_escapes_row: + write_raw(zero_width_escapes_row[c]) + + output_char(new_char) + current_pos = Point(x=current_pos.x + char_width, y=current_pos.y) + + c += char_width + + # If the new line is shorter, trim it. + if previous_screen and new_max_line_len < previous_max_line_len: + current_pos = move_cursor(Point(x=new_max_line_len + 1, y=y)) + reset_attributes() + output.erase_end_of_line() + + # Correctly reserve vertical space as required by the layout. + # When this is a new screen (drawn for the first time), or for some reason + # higher than the previous one. Move the cursor once to the bottom of the + # output. That way, we're sure that the terminal scrolls up, even when the + # lower lines of the canvas just contain whitespace. + + # The most obvious reason that we actually want this behavior is the avoid + # the artifact of the input scrolling when the completion menu is shown. + # (If the scrolling is actually wanted, the layout can still be build in a + # way to behave that way by setting a dynamic height.) + if current_height > previous_screen.height: + current_pos = move_cursor(Point(x=0, y=current_height - 1)) + + # Move cursor: + if is_done: + current_pos = move_cursor(Point(x=0, y=current_height)) + output.erase_down() + else: + current_pos = move_cursor(screen.get_cursor_position(app.layout.current_window)) + + if is_done or not full_screen: + output.enable_autowrap() + + # Always reset the color attributes. This is important because a background + # thread could print data to stdout and we want that to be displayed in the + # default colors. (Also, if a background color has been set, many terminals + # give weird artifacts on resize events.) + reset_attributes() + + if screen.show_cursor or is_done: + output.show_cursor() + + return current_pos, last_style + + +class HeightIsUnknownError(Exception): + "Information unavailable. Did not yet receive the CPR response." + + +class _StyleStringToAttrsCache(Dict[str, Attrs]): + """ + A cache structure that maps style strings to :class:`.Attr`. + (This is an important speed up.) + """ + + def __init__( + self, + get_attrs_for_style_str: Callable[[str], Attrs], + style_transformation: StyleTransformation, + ) -> None: + self.get_attrs_for_style_str = get_attrs_for_style_str + self.style_transformation = style_transformation + + def __missing__(self, style_str: str) -> Attrs: + attrs = self.get_attrs_for_style_str(style_str) + attrs = self.style_transformation.transform_attrs(attrs) + + self[style_str] = attrs + return attrs + + +class _StyleStringHasStyleCache(Dict[str, bool]): + """ + Cache for remember which style strings don't render the default output + style (default fg/bg, no underline and no reverse and no blink). That way + we know that we should render these cells, even when they're empty (when + they contain a space). + + Note: we don't consider bold/italic/hidden because they don't change the + output if there's no text in the cell. + """ + + def __init__(self, style_string_to_attrs: dict[str, Attrs]) -> None: + self.style_string_to_attrs = style_string_to_attrs + + def __missing__(self, style_str: str) -> bool: + attrs = self.style_string_to_attrs[style_str] + is_default = bool( + attrs.color + or attrs.bgcolor + or attrs.underline + or attrs.strike + or attrs.blink + or attrs.reverse + ) + + self[style_str] = is_default + return is_default + + +class CPR_Support(Enum): + "Enum: whether or not CPR is supported." + + SUPPORTED = "SUPPORTED" + NOT_SUPPORTED = "NOT_SUPPORTED" + UNKNOWN = "UNKNOWN" + + +class Renderer: + """ + Typical usage: + + :: + + output = Vt100_Output.from_pty(sys.stdout) + r = Renderer(style, output) + r.render(app, layout=...) + """ + + CPR_TIMEOUT = 2 # Time to wait until we consider CPR to be not supported. + + def __init__( + self, + style: BaseStyle, + output: Output, + full_screen: bool = False, + mouse_support: FilterOrBool = False, + cpr_not_supported_callback: Callable[[], None] | None = None, + ) -> None: + self.style = style + self.output = output + self.full_screen = full_screen + self.mouse_support = to_filter(mouse_support) + self.cpr_not_supported_callback = cpr_not_supported_callback + + self._in_alternate_screen = False + self._mouse_support_enabled = False + self._bracketed_paste_enabled = False + self._cursor_key_mode_reset = False + + # Future set when we are waiting for a CPR flag. + self._waiting_for_cpr_futures: deque[Future[None]] = deque() + self.cpr_support = CPR_Support.UNKNOWN + + if not output.responds_to_cpr: + self.cpr_support = CPR_Support.NOT_SUPPORTED + + # Cache for the style. + self._attrs_for_style: _StyleStringToAttrsCache | None = None + self._style_string_has_style: _StyleStringHasStyleCache | None = None + self._last_style_hash: Hashable | None = None + self._last_transformation_hash: Hashable | None = None + self._last_color_depth: ColorDepth | None = None + + self.reset(_scroll=True) + + def reset(self, _scroll: bool = False, leave_alternate_screen: bool = True) -> None: + # Reset position + self._cursor_pos = Point(x=0, y=0) + + # Remember the last screen instance between renderers. This way, + # we can create a `diff` between two screens and only output the + # difference. It's also to remember the last height. (To show for + # instance a toolbar at the bottom position.) + self._last_screen: Screen | None = None + self._last_size: Size | None = None + self._last_style: str | None = None + self._last_cursor_shape: CursorShape | None = None + + # Default MouseHandlers. (Just empty.) + self.mouse_handlers = MouseHandlers() + + #: Space from the top of the layout, until the bottom of the terminal. + #: We don't know this until a `report_absolute_cursor_row` call. + self._min_available_height = 0 + + # In case of Windows, also make sure to scroll to the current cursor + # position. (Only when rendering the first time.) + # It does nothing for vt100 terminals. + if _scroll: + self.output.scroll_buffer_to_prompt() + + # Quit alternate screen. + if self._in_alternate_screen and leave_alternate_screen: + self.output.quit_alternate_screen() + self._in_alternate_screen = False + + # Disable mouse support. + if self._mouse_support_enabled: + self.output.disable_mouse_support() + self._mouse_support_enabled = False + + # Disable bracketed paste. + if self._bracketed_paste_enabled: + self.output.disable_bracketed_paste() + self._bracketed_paste_enabled = False + + self.output.reset_cursor_shape() + + # NOTE: No need to set/reset cursor key mode here. + + # Flush output. `disable_mouse_support` needs to write to stdout. + self.output.flush() + + @property + def last_rendered_screen(self) -> Screen | None: + """ + The `Screen` class that was generated during the last rendering. + This can be `None`. + """ + return self._last_screen + + @property + def height_is_known(self) -> bool: + """ + True when the height from the cursor until the bottom of the terminal + is known. (It's often nicer to draw bottom toolbars only if the height + is known, in order to avoid flickering when the CPR response arrives.) + """ + if self.full_screen or self._min_available_height > 0: + return True + try: + self._min_available_height = self.output.get_rows_below_cursor_position() + return True + except NotImplementedError: + return False + + @property + def rows_above_layout(self) -> int: + """ + Return the number of rows visible in the terminal above the layout. + """ + if self._in_alternate_screen: + return 0 + elif self._min_available_height > 0: + total_rows = self.output.get_size().rows + last_screen_height = self._last_screen.height if self._last_screen else 0 + return total_rows - max(self._min_available_height, last_screen_height) + else: + raise HeightIsUnknownError("Rows above layout is unknown.") + + def request_absolute_cursor_position(self) -> None: + """ + Get current cursor position. + + We do this to calculate the minimum available height that we can + consume for rendering the prompt. This is the available space below te + cursor. + + For vt100: Do CPR request. (answer will arrive later.) + For win32: Do API call. (Answer comes immediately.) + """ + # Only do this request when the cursor is at the top row. (after a + # clear or reset). We will rely on that in `report_absolute_cursor_row`. + assert self._cursor_pos.y == 0 + + # In full-screen mode, always use the total height as min-available-height. + if self.full_screen: + self._min_available_height = self.output.get_size().rows + return + + # For Win32, we have an API call to get the number of rows below the + # cursor. + try: + self._min_available_height = self.output.get_rows_below_cursor_position() + return + except NotImplementedError: + pass + + # Use CPR. + if self.cpr_support == CPR_Support.NOT_SUPPORTED: + return + + def do_cpr() -> None: + # Asks for a cursor position report (CPR). + self._waiting_for_cpr_futures.append(Future()) + self.output.ask_for_cpr() + + if self.cpr_support == CPR_Support.SUPPORTED: + do_cpr() + return + + # If we don't know whether CPR is supported, only do a request if + # none is pending, and test it, using a timer. + if self.waiting_for_cpr: + return + + do_cpr() + + async def timer() -> None: + await sleep(self.CPR_TIMEOUT) + + # Not set in the meantime -> not supported. + if self.cpr_support == CPR_Support.UNKNOWN: + self.cpr_support = CPR_Support.NOT_SUPPORTED + + if self.cpr_not_supported_callback: + # Make sure to call this callback in the main thread. + self.cpr_not_supported_callback() + + get_app().create_background_task(timer()) + + def report_absolute_cursor_row(self, row: int) -> None: + """ + To be called when we know the absolute cursor position. + (As an answer of a "Cursor Position Request" response.) + """ + self.cpr_support = CPR_Support.SUPPORTED + + # Calculate the amount of rows from the cursor position until the + # bottom of the terminal. + total_rows = self.output.get_size().rows + rows_below_cursor = total_rows - row + 1 + + # Set the minimum available height. + self._min_available_height = rows_below_cursor + + # Pop and set waiting for CPR future. + try: + f = self._waiting_for_cpr_futures.popleft() + except IndexError: + pass # Received CPR response without having a CPR. + else: + f.set_result(None) + + @property + def waiting_for_cpr(self) -> bool: + """ + Waiting for CPR flag. True when we send the request, but didn't got a + response. + """ + return bool(self._waiting_for_cpr_futures) + + async def wait_for_cpr_responses(self, timeout: int = 1) -> None: + """ + Wait for a CPR response. + """ + cpr_futures = list(self._waiting_for_cpr_futures) # Make copy. + + # When there are no CPRs in the queue. Don't do anything. + if not cpr_futures or self.cpr_support == CPR_Support.NOT_SUPPORTED: + return None + + async def wait_for_responses() -> None: + for response_f in cpr_futures: + await response_f + + async def wait_for_timeout() -> None: + await sleep(timeout) + + # Got timeout, erase queue. + for response_f in cpr_futures: + response_f.cancel() + self._waiting_for_cpr_futures = deque() + + tasks = { + ensure_future(wait_for_responses()), + ensure_future(wait_for_timeout()), + } + _, pending = await wait(tasks, return_when=FIRST_COMPLETED) + for task in pending: + task.cancel() + + def render( + self, app: Application[Any], layout: Layout, is_done: bool = False + ) -> None: + """ + Render the current interface to the output. + + :param is_done: When True, put the cursor at the end of the interface. We + won't print any changes to this part. + """ + output = self.output + + # Enter alternate screen. + if self.full_screen and not self._in_alternate_screen: + self._in_alternate_screen = True + output.enter_alternate_screen() + + # Enable bracketed paste. + if not self._bracketed_paste_enabled: + self.output.enable_bracketed_paste() + self._bracketed_paste_enabled = True + + # Reset cursor key mode. + if not self._cursor_key_mode_reset: + self.output.reset_cursor_key_mode() + self._cursor_key_mode_reset = True + + # Enable/disable mouse support. + needs_mouse_support = self.mouse_support() + + if needs_mouse_support and not self._mouse_support_enabled: + output.enable_mouse_support() + self._mouse_support_enabled = True + + elif not needs_mouse_support and self._mouse_support_enabled: + output.disable_mouse_support() + self._mouse_support_enabled = False + + # Create screen and write layout to it. + size = output.get_size() + screen = Screen() + screen.show_cursor = False # Hide cursor by default, unless one of the + # containers decides to display it. + mouse_handlers = MouseHandlers() + + # Calculate height. + if self.full_screen: + height = size.rows + elif is_done: + # When we are done, we don't necessary want to fill up until the bottom. + height = layout.container.preferred_height( + size.columns, size.rows + ).preferred + else: + last_height = self._last_screen.height if self._last_screen else 0 + height = max( + self._min_available_height, + last_height, + layout.container.preferred_height(size.columns, size.rows).preferred, + ) + + height = min(height, size.rows) + + # When the size changes, don't consider the previous screen. + if self._last_size != size: + self._last_screen = None + + # When we render using another style or another color depth, do a full + # repaint. (Forget about the previous rendered screen.) + # (But note that we still use _last_screen to calculate the height.) + if ( + self.style.invalidation_hash() != self._last_style_hash + or app.style_transformation.invalidation_hash() + != self._last_transformation_hash + or app.color_depth != self._last_color_depth + ): + self._last_screen = None + self._attrs_for_style = None + self._style_string_has_style = None + + if self._attrs_for_style is None: + self._attrs_for_style = _StyleStringToAttrsCache( + self.style.get_attrs_for_style_str, app.style_transformation + ) + if self._style_string_has_style is None: + self._style_string_has_style = _StyleStringHasStyleCache( + self._attrs_for_style + ) + + self._last_style_hash = self.style.invalidation_hash() + self._last_transformation_hash = app.style_transformation.invalidation_hash() + self._last_color_depth = app.color_depth + + layout.container.write_to_screen( + screen, + mouse_handlers, + WritePosition(xpos=0, ypos=0, width=size.columns, height=height), + parent_style="", + erase_bg=False, + z_index=None, + ) + screen.draw_all_floats() + + # When grayed. Replace all styles in the new screen. + if app.exit_style: + screen.append_style_to_content(app.exit_style) + + # Process diff and write to output. + self._cursor_pos, self._last_style = _output_screen_diff( + app, + output, + screen, + self._cursor_pos, + app.color_depth, + self._last_screen, + self._last_style, + is_done, + full_screen=self.full_screen, + attrs_for_style_string=self._attrs_for_style, + style_string_has_style=self._style_string_has_style, + size=size, + previous_width=(self._last_size.columns if self._last_size else 0), + ) + self._last_screen = screen + self._last_size = size + self.mouse_handlers = mouse_handlers + + # Handle cursor shapes. + new_cursor_shape = app.cursor.get_cursor_shape(app) + if ( + self._last_cursor_shape is None + or self._last_cursor_shape != new_cursor_shape + ): + output.set_cursor_shape(new_cursor_shape) + self._last_cursor_shape = new_cursor_shape + + # Flush buffered output. + output.flush() + + # Set visible windows in layout. + app.layout.visible_windows = screen.visible_windows + + if is_done: + self.reset() + + def erase(self, leave_alternate_screen: bool = True) -> None: + """ + Hide all output and put the cursor back at the first line. This is for + instance used for running a system command (while hiding the CLI) and + later resuming the same CLI.) + + :param leave_alternate_screen: When True, and when inside an alternate + screen buffer, quit the alternate screen. + """ + output = self.output + + output.cursor_backward(self._cursor_pos.x) + output.cursor_up(self._cursor_pos.y) + output.erase_down() + output.reset_attributes() + output.enable_autowrap() + + output.flush() + + self.reset(leave_alternate_screen=leave_alternate_screen) + + def clear(self) -> None: + """ + Clear screen and go to 0,0 + """ + # Erase current output first. + self.erase() + + # Send "Erase Screen" command and go to (0, 0). + output = self.output + + output.erase_screen() + output.cursor_goto(0, 0) + output.flush() + + self.request_absolute_cursor_position() + + +def print_formatted_text( + output: Output, + formatted_text: AnyFormattedText, + style: BaseStyle, + style_transformation: StyleTransformation | None = None, + color_depth: ColorDepth | None = None, +) -> None: + """ + Print a list of (style_str, text) tuples in the given style to the output. + """ + fragments = to_formatted_text(formatted_text) + style_transformation = style_transformation or DummyStyleTransformation() + color_depth = color_depth or output.get_default_color_depth() + + # Reset first. + output.reset_attributes() + output.enable_autowrap() + last_attrs: Attrs | None = None + + # Print all (style_str, text) tuples. + attrs_for_style_string = _StyleStringToAttrsCache( + style.get_attrs_for_style_str, style_transformation + ) + + for style_str, text, *_ in fragments: + attrs = attrs_for_style_string[style_str] + + # Set style attributes if something changed. + if attrs != last_attrs: + if attrs: + output.set_attributes(attrs, color_depth) + else: + output.reset_attributes() + last_attrs = attrs + + # Print escape sequences as raw output + if "[ZeroWidthEscape]" in style_str: + output.write_raw(text) + else: + # Eliminate carriage returns + text = text.replace("\r", "") + # Insert a carriage return before every newline (important when the + # front-end is a telnet client). + text = text.replace("\n", "\r\n") + output.write(text) + + # Reset again. + output.reset_attributes() + output.flush() diff --git a/src/prompt_toolkit/search.py b/src/prompt_toolkit/search.py new file mode 100644 index 0000000..fd90a04 --- /dev/null +++ b/src/prompt_toolkit/search.py @@ -0,0 +1,230 @@ +""" +Search operations. + +For the key bindings implementation with attached filters, check +`prompt_toolkit.key_binding.bindings.search`. (Use these for new key bindings +instead of calling these function directly.) +""" +from __future__ import annotations + +from enum import Enum +from typing import TYPE_CHECKING + +from .application.current import get_app +from .filters import FilterOrBool, is_searching, to_filter +from .key_binding.vi_state import InputMode + +if TYPE_CHECKING: + from prompt_toolkit.layout.controls import BufferControl, SearchBufferControl + from prompt_toolkit.layout.layout import Layout + +__all__ = [ + "SearchDirection", + "start_search", + "stop_search", +] + + +class SearchDirection(Enum): + FORWARD = "FORWARD" + BACKWARD = "BACKWARD" + + +class SearchState: + """ + A search 'query', associated with a search field (like a SearchToolbar). + + Every searchable `BufferControl` points to a `search_buffer_control` + (another `BufferControls`) which represents the search field. The + `SearchState` attached to that search field is used for storing the current + search query. + + It is possible to have one searchfield for multiple `BufferControls`. In + that case, they'll share the same `SearchState`. + If there are multiple `BufferControls` that display the same `Buffer`, then + they can have a different `SearchState` each (if they have a different + search control). + """ + + __slots__ = ("text", "direction", "ignore_case") + + def __init__( + self, + text: str = "", + direction: SearchDirection = SearchDirection.FORWARD, + ignore_case: FilterOrBool = False, + ) -> None: + self.text = text + self.direction = direction + self.ignore_case = to_filter(ignore_case) + + def __repr__(self) -> str: + return "{}({!r}, direction={!r}, ignore_case={!r})".format( + self.__class__.__name__, + self.text, + self.direction, + self.ignore_case, + ) + + def __invert__(self) -> SearchState: + """ + Create a new SearchState where backwards becomes forwards and the other + way around. + """ + if self.direction == SearchDirection.BACKWARD: + direction = SearchDirection.FORWARD + else: + direction = SearchDirection.BACKWARD + + return SearchState( + text=self.text, direction=direction, ignore_case=self.ignore_case + ) + + +def start_search( + buffer_control: BufferControl | None = None, + direction: SearchDirection = SearchDirection.FORWARD, +) -> None: + """ + Start search through the given `buffer_control` using the + `search_buffer_control`. + + :param buffer_control: Start search for this `BufferControl`. If not given, + search through the current control. + """ + from prompt_toolkit.layout.controls import BufferControl + + assert buffer_control is None or isinstance(buffer_control, BufferControl) + + layout = get_app().layout + + # When no control is given, use the current control if that's a BufferControl. + if buffer_control is None: + if not isinstance(layout.current_control, BufferControl): + return + buffer_control = layout.current_control + + # Only if this control is searchable. + search_buffer_control = buffer_control.search_buffer_control + + if search_buffer_control: + buffer_control.search_state.direction = direction + + # Make sure to focus the search BufferControl + layout.focus(search_buffer_control) + + # Remember search link. + layout.search_links[search_buffer_control] = buffer_control + + # If we're in Vi mode, make sure to go into insert mode. + get_app().vi_state.input_mode = InputMode.INSERT + + +def stop_search(buffer_control: BufferControl | None = None) -> None: + """ + Stop search through the given `buffer_control`. + """ + layout = get_app().layout + + if buffer_control is None: + buffer_control = layout.search_target_buffer_control + if buffer_control is None: + # (Should not happen, but possible when `stop_search` is called + # when we're not searching.) + return + search_buffer_control = buffer_control.search_buffer_control + else: + assert buffer_control in layout.search_links.values() + search_buffer_control = _get_reverse_search_links(layout)[buffer_control] + + # Focus the original buffer again. + layout.focus(buffer_control) + + if search_buffer_control is not None: + # Remove the search link. + del layout.search_links[search_buffer_control] + + # Reset content of search control. + search_buffer_control.buffer.reset() + + # If we're in Vi mode, go back to navigation mode. + get_app().vi_state.input_mode = InputMode.NAVIGATION + + +def do_incremental_search(direction: SearchDirection, count: int = 1) -> None: + """ + Apply search, but keep search buffer focused. + """ + assert is_searching() + + layout = get_app().layout + + # Only search if the current control is a `BufferControl`. + from prompt_toolkit.layout.controls import BufferControl + + search_control = layout.current_control + if not isinstance(search_control, BufferControl): + return + + prev_control = layout.search_target_buffer_control + if prev_control is None: + return + search_state = prev_control.search_state + + # Update search_state. + direction_changed = search_state.direction != direction + + search_state.text = search_control.buffer.text + search_state.direction = direction + + # Apply search to current buffer. + if not direction_changed: + prev_control.buffer.apply_search( + search_state, include_current_position=False, count=count + ) + + +def accept_search() -> None: + """ + Accept current search query. Focus original `BufferControl` again. + """ + layout = get_app().layout + + search_control = layout.current_control + target_buffer_control = layout.search_target_buffer_control + + from prompt_toolkit.layout.controls import BufferControl + + if not isinstance(search_control, BufferControl): + return + if target_buffer_control is None: + return + + search_state = target_buffer_control.search_state + + # Update search state. + if search_control.buffer.text: + search_state.text = search_control.buffer.text + + # Apply search. + target_buffer_control.buffer.apply_search( + search_state, include_current_position=True + ) + + # Add query to history of search line. + search_control.buffer.append_to_history() + + # Stop search and focus previous control again. + stop_search(target_buffer_control) + + +def _get_reverse_search_links( + layout: Layout, +) -> dict[BufferControl, SearchBufferControl]: + """ + Return mapping from BufferControl to SearchBufferControl. + """ + return { + buffer_control: search_buffer_control + for search_buffer_control, buffer_control in layout.search_links.items() + } diff --git a/src/prompt_toolkit/selection.py b/src/prompt_toolkit/selection.py new file mode 100644 index 0000000..2158fa9 --- /dev/null +++ b/src/prompt_toolkit/selection.py @@ -0,0 +1,61 @@ +""" +Data structures for the selection. +""" +from __future__ import annotations + +from enum import Enum + +__all__ = [ + "SelectionType", + "PasteMode", + "SelectionState", +] + + +class SelectionType(Enum): + """ + Type of selection. + """ + + #: Characters. (Visual in Vi.) + CHARACTERS = "CHARACTERS" + + #: Whole lines. (Visual-Line in Vi.) + LINES = "LINES" + + #: A block selection. (Visual-Block in Vi.) + BLOCK = "BLOCK" + + +class PasteMode(Enum): + EMACS = "EMACS" # Yank like emacs. + VI_AFTER = "VI_AFTER" # When pressing 'p' in Vi. + VI_BEFORE = "VI_BEFORE" # When pressing 'P' in Vi. + + +class SelectionState: + """ + State of the current selection. + + :param original_cursor_position: int + :param type: :class:`~.SelectionType` + """ + + def __init__( + self, + original_cursor_position: int = 0, + type: SelectionType = SelectionType.CHARACTERS, + ) -> None: + self.original_cursor_position = original_cursor_position + self.type = type + self.shift_mode = False + + def enter_shift_mode(self) -> None: + self.shift_mode = True + + def __repr__(self) -> str: + return "{}(original_cursor_position={!r}, type={!r})".format( + self.__class__.__name__, + self.original_cursor_position, + self.type, + ) diff --git a/src/prompt_toolkit/shortcuts/__init__.py b/src/prompt_toolkit/shortcuts/__init__.py new file mode 100644 index 0000000..49e5ac4 --- /dev/null +++ b/src/prompt_toolkit/shortcuts/__init__.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from .dialogs import ( + button_dialog, + checkboxlist_dialog, + input_dialog, + message_dialog, + progress_dialog, + radiolist_dialog, + yes_no_dialog, +) +from .progress_bar import ProgressBar, ProgressBarCounter +from .prompt import ( + CompleteStyle, + PromptSession, + confirm, + create_confirm_session, + prompt, +) +from .utils import clear, clear_title, print_container, print_formatted_text, set_title + +__all__ = [ + # Dialogs. + "input_dialog", + "message_dialog", + "progress_dialog", + "checkboxlist_dialog", + "radiolist_dialog", + "yes_no_dialog", + "button_dialog", + # Prompts. + "PromptSession", + "prompt", + "confirm", + "create_confirm_session", + "CompleteStyle", + # Progress bars. + "ProgressBar", + "ProgressBarCounter", + # Utils. + "clear", + "clear_title", + "print_container", + "print_formatted_text", + "set_title", +] diff --git a/src/prompt_toolkit/shortcuts/dialogs.py b/src/prompt_toolkit/shortcuts/dialogs.py new file mode 100644 index 0000000..d78e7db --- /dev/null +++ b/src/prompt_toolkit/shortcuts/dialogs.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +import functools +from asyncio import get_running_loop +from typing import Any, Callable, Sequence, TypeVar + +from prompt_toolkit.application import Application +from prompt_toolkit.application.current import get_app +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.completion import Completer +from prompt_toolkit.eventloop import run_in_executor_with_context +from prompt_toolkit.filters import FilterOrBool +from prompt_toolkit.formatted_text import AnyFormattedText +from prompt_toolkit.key_binding.bindings.focus import focus_next, focus_previous +from prompt_toolkit.key_binding.defaults import load_key_bindings +from prompt_toolkit.key_binding.key_bindings import KeyBindings, merge_key_bindings +from prompt_toolkit.layout import Layout +from prompt_toolkit.layout.containers import AnyContainer, HSplit +from prompt_toolkit.layout.dimension import Dimension as D +from prompt_toolkit.styles import BaseStyle +from prompt_toolkit.validation import Validator +from prompt_toolkit.widgets import ( + Box, + Button, + CheckboxList, + Dialog, + Label, + ProgressBar, + RadioList, + TextArea, + ValidationToolbar, +) + +__all__ = [ + "yes_no_dialog", + "button_dialog", + "input_dialog", + "message_dialog", + "radiolist_dialog", + "checkboxlist_dialog", + "progress_dialog", +] + + +def yes_no_dialog( + title: AnyFormattedText = "", + text: AnyFormattedText = "", + yes_text: str = "Yes", + no_text: str = "No", + style: BaseStyle | None = None, +) -> Application[bool]: + """ + Display a Yes/No dialog. + Return a boolean. + """ + + def yes_handler() -> None: + get_app().exit(result=True) + + def no_handler() -> None: + get_app().exit(result=False) + + dialog = Dialog( + title=title, + body=Label(text=text, dont_extend_height=True), + buttons=[ + Button(text=yes_text, handler=yes_handler), + Button(text=no_text, handler=no_handler), + ], + with_background=True, + ) + + return _create_app(dialog, style) + + +_T = TypeVar("_T") + + +def button_dialog( + title: AnyFormattedText = "", + text: AnyFormattedText = "", + buttons: list[tuple[str, _T]] = [], + style: BaseStyle | None = None, +) -> Application[_T]: + """ + Display a dialog with button choices (given as a list of tuples). + Return the value associated with button. + """ + + def button_handler(v: _T) -> None: + get_app().exit(result=v) + + dialog = Dialog( + title=title, + body=Label(text=text, dont_extend_height=True), + buttons=[ + Button(text=t, handler=functools.partial(button_handler, v)) + for t, v in buttons + ], + with_background=True, + ) + + return _create_app(dialog, style) + + +def input_dialog( + title: AnyFormattedText = "", + text: AnyFormattedText = "", + ok_text: str = "OK", + cancel_text: str = "Cancel", + completer: Completer | None = None, + validator: Validator | None = None, + password: FilterOrBool = False, + style: BaseStyle | None = None, + default: str = "", +) -> Application[str]: + """ + Display a text input box. + Return the given text, or None when cancelled. + """ + + def accept(buf: Buffer) -> bool: + get_app().layout.focus(ok_button) + return True # Keep text. + + def ok_handler() -> None: + get_app().exit(result=textfield.text) + + ok_button = Button(text=ok_text, handler=ok_handler) + cancel_button = Button(text=cancel_text, handler=_return_none) + + textfield = TextArea( + text=default, + multiline=False, + password=password, + completer=completer, + validator=validator, + accept_handler=accept, + ) + + dialog = Dialog( + title=title, + body=HSplit( + [ + Label(text=text, dont_extend_height=True), + textfield, + ValidationToolbar(), + ], + padding=D(preferred=1, max=1), + ), + buttons=[ok_button, cancel_button], + with_background=True, + ) + + return _create_app(dialog, style) + + +def message_dialog( + title: AnyFormattedText = "", + text: AnyFormattedText = "", + ok_text: str = "Ok", + style: BaseStyle | None = None, +) -> Application[None]: + """ + Display a simple message box and wait until the user presses enter. + """ + dialog = Dialog( + title=title, + body=Label(text=text, dont_extend_height=True), + buttons=[Button(text=ok_text, handler=_return_none)], + with_background=True, + ) + + return _create_app(dialog, style) + + +def radiolist_dialog( + title: AnyFormattedText = "", + text: AnyFormattedText = "", + ok_text: str = "Ok", + cancel_text: str = "Cancel", + values: Sequence[tuple[_T, AnyFormattedText]] | None = None, + default: _T | None = None, + style: BaseStyle | None = None, +) -> Application[_T]: + """ + Display a simple list of element the user can choose amongst. + + Only one element can be selected at a time using Arrow keys and Enter. + The focus can be moved between the list and the Ok/Cancel button with tab. + """ + if values is None: + values = [] + + def ok_handler() -> None: + get_app().exit(result=radio_list.current_value) + + radio_list = RadioList(values=values, default=default) + + dialog = Dialog( + title=title, + body=HSplit( + [Label(text=text, dont_extend_height=True), radio_list], + padding=1, + ), + buttons=[ + Button(text=ok_text, handler=ok_handler), + Button(text=cancel_text, handler=_return_none), + ], + with_background=True, + ) + + return _create_app(dialog, style) + + +def checkboxlist_dialog( + title: AnyFormattedText = "", + text: AnyFormattedText = "", + ok_text: str = "Ok", + cancel_text: str = "Cancel", + values: Sequence[tuple[_T, AnyFormattedText]] | None = None, + default_values: Sequence[_T] | None = None, + style: BaseStyle | None = None, +) -> Application[list[_T]]: + """ + Display a simple list of element the user can choose multiple values amongst. + + Several elements can be selected at a time using Arrow keys and Enter. + The focus can be moved between the list and the Ok/Cancel button with tab. + """ + if values is None: + values = [] + + def ok_handler() -> None: + get_app().exit(result=cb_list.current_values) + + cb_list = CheckboxList(values=values, default_values=default_values) + + dialog = Dialog( + title=title, + body=HSplit( + [Label(text=text, dont_extend_height=True), cb_list], + padding=1, + ), + buttons=[ + Button(text=ok_text, handler=ok_handler), + Button(text=cancel_text, handler=_return_none), + ], + with_background=True, + ) + + return _create_app(dialog, style) + + +def progress_dialog( + title: AnyFormattedText = "", + text: AnyFormattedText = "", + run_callback: Callable[[Callable[[int], None], Callable[[str], None]], None] = ( + lambda *a: None + ), + style: BaseStyle | None = None, +) -> Application[None]: + """ + :param run_callback: A function that receives as input a `set_percentage` + function and it does the work. + """ + loop = get_running_loop() + progressbar = ProgressBar() + text_area = TextArea( + focusable=False, + # Prefer this text area as big as possible, to avoid having a window + # that keeps resizing when we add text to it. + height=D(preferred=10**10), + ) + + dialog = Dialog( + body=HSplit( + [ + Box(Label(text=text)), + Box(text_area, padding=D.exact(1)), + progressbar, + ] + ), + title=title, + with_background=True, + ) + app = _create_app(dialog, style) + + def set_percentage(value: int) -> None: + progressbar.percentage = int(value) + app.invalidate() + + def log_text(text: str) -> None: + loop.call_soon_threadsafe(text_area.buffer.insert_text, text) + app.invalidate() + + # Run the callback in the executor. When done, set a return value for the + # UI, so that it quits. + def start() -> None: + try: + run_callback(set_percentage, log_text) + finally: + app.exit() + + def pre_run() -> None: + run_in_executor_with_context(start) + + app.pre_run_callables.append(pre_run) + + return app + + +def _create_app(dialog: AnyContainer, style: BaseStyle | None) -> Application[Any]: + # Key bindings. + bindings = KeyBindings() + bindings.add("tab")(focus_next) + bindings.add("s-tab")(focus_previous) + + return Application( + layout=Layout(dialog), + key_bindings=merge_key_bindings([load_key_bindings(), bindings]), + mouse_support=True, + style=style, + full_screen=True, + ) + + +def _return_none() -> None: + "Button handler that returns None." + get_app().exit() diff --git a/src/prompt_toolkit/shortcuts/progress_bar/__init__.py b/src/prompt_toolkit/shortcuts/progress_bar/__init__.py new file mode 100644 index 0000000..2261a5b --- /dev/null +++ b/src/prompt_toolkit/shortcuts/progress_bar/__init__.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from .base import ProgressBar, ProgressBarCounter +from .formatters import ( + Bar, + Formatter, + IterationsPerSecond, + Label, + Percentage, + Progress, + Rainbow, + SpinningWheel, + Text, + TimeElapsed, + TimeLeft, +) + +__all__ = [ + "ProgressBar", + "ProgressBarCounter", + # Formatters. + "Formatter", + "Text", + "Label", + "Percentage", + "Bar", + "Progress", + "TimeElapsed", + "TimeLeft", + "IterationsPerSecond", + "SpinningWheel", + "Rainbow", +] diff --git a/src/prompt_toolkit/shortcuts/progress_bar/base.py b/src/prompt_toolkit/shortcuts/progress_bar/base.py new file mode 100644 index 0000000..21aa1be --- /dev/null +++ b/src/prompt_toolkit/shortcuts/progress_bar/base.py @@ -0,0 +1,448 @@ +""" +Progress bar implementation on top of prompt_toolkit. + +:: + + with ProgressBar(...) as pb: + for item in pb(data): + ... +""" +from __future__ import annotations + +import contextvars +import datetime +import functools +import os +import signal +import threading +import traceback +from typing import ( + Callable, + Generic, + Iterable, + Iterator, + Sequence, + Sized, + TextIO, + TypeVar, + cast, +) + +from prompt_toolkit.application import Application +from prompt_toolkit.application.current import get_app_session +from prompt_toolkit.filters import Condition, is_done, renderer_height_is_known +from prompt_toolkit.formatted_text import ( + AnyFormattedText, + StyleAndTextTuples, + to_formatted_text, +) +from prompt_toolkit.input import Input +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.key_binding.key_processor import KeyPressEvent +from prompt_toolkit.layout import ( + ConditionalContainer, + FormattedTextControl, + HSplit, + Layout, + VSplit, + Window, +) +from prompt_toolkit.layout.controls import UIContent, UIControl +from prompt_toolkit.layout.dimension import AnyDimension, D +from prompt_toolkit.output import ColorDepth, Output +from prompt_toolkit.styles import BaseStyle +from prompt_toolkit.utils import in_main_thread + +from .formatters import Formatter, create_default_formatters + +__all__ = ["ProgressBar"] + +E = KeyPressEvent + +_SIGWINCH = getattr(signal, "SIGWINCH", None) + + +def create_key_bindings(cancel_callback: Callable[[], None] | None) -> KeyBindings: + """ + Key bindings handled by the progress bar. + (The main thread is not supposed to handle any key bindings.) + """ + kb = KeyBindings() + + @kb.add("c-l") + def _clear(event: E) -> None: + event.app.renderer.clear() + + if cancel_callback is not None: + + @kb.add("c-c") + def _interrupt(event: E) -> None: + "Kill the 'body' of the progress bar, but only if we run from the main thread." + assert cancel_callback is not None + cancel_callback() + + return kb + + +_T = TypeVar("_T") + + +class ProgressBar: + """ + Progress bar context manager. + + Usage :: + + with ProgressBar(...) as pb: + for item in pb(data): + ... + + :param title: Text to be displayed above the progress bars. This can be a + callable or formatted text as well. + :param formatters: List of :class:`.Formatter` instances. + :param bottom_toolbar: Text to be displayed in the bottom toolbar. This + can be a callable or formatted text. + :param style: :class:`prompt_toolkit.styles.BaseStyle` instance. + :param key_bindings: :class:`.KeyBindings` instance. + :param cancel_callback: Callback function that's called when control-c is + pressed by the user. This can be used for instance to start "proper" + cancellation if the wrapped code supports it. + :param file: The file object used for rendering, by default `sys.stderr` is used. + + :param color_depth: `prompt_toolkit` `ColorDepth` instance. + :param output: :class:`~prompt_toolkit.output.Output` instance. + :param input: :class:`~prompt_toolkit.input.Input` instance. + """ + + def __init__( + self, + title: AnyFormattedText = None, + formatters: Sequence[Formatter] | None = None, + bottom_toolbar: AnyFormattedText = None, + style: BaseStyle | None = None, + key_bindings: KeyBindings | None = None, + cancel_callback: Callable[[], None] | None = None, + file: TextIO | None = None, + color_depth: ColorDepth | None = None, + output: Output | None = None, + input: Input | None = None, + ) -> None: + self.title = title + self.formatters = formatters or create_default_formatters() + self.bottom_toolbar = bottom_toolbar + self.counters: list[ProgressBarCounter[object]] = [] + self.style = style + self.key_bindings = key_bindings + self.cancel_callback = cancel_callback + + # If no `cancel_callback` was given, and we're creating the progress + # bar from the main thread. Cancel by sending a `KeyboardInterrupt` to + # the main thread. + if self.cancel_callback is None and in_main_thread(): + + def keyboard_interrupt_to_main_thread() -> None: + os.kill(os.getpid(), signal.SIGINT) + + self.cancel_callback = keyboard_interrupt_to_main_thread + + # Note that we use __stderr__ as default error output, because that + # works best with `patch_stdout`. + self.color_depth = color_depth + self.output = output or get_app_session().output + self.input = input or get_app_session().input + + self._thread: threading.Thread | None = None + + self._has_sigwinch = False + self._app_started = threading.Event() + + def __enter__(self) -> ProgressBar: + # Create UI Application. + title_toolbar = ConditionalContainer( + Window( + FormattedTextControl(lambda: self.title), + height=1, + style="class:progressbar,title", + ), + filter=Condition(lambda: self.title is not None), + ) + + bottom_toolbar = ConditionalContainer( + Window( + FormattedTextControl( + lambda: self.bottom_toolbar, style="class:bottom-toolbar.text" + ), + style="class:bottom-toolbar", + height=1, + ), + filter=~is_done + & renderer_height_is_known + & Condition(lambda: self.bottom_toolbar is not None), + ) + + def width_for_formatter(formatter: Formatter) -> AnyDimension: + # Needs to be passed as callable (partial) to the 'width' + # parameter, because we want to call it on every resize. + return formatter.get_width(progress_bar=self) + + progress_controls = [ + Window( + content=_ProgressControl(self, f, self.cancel_callback), + width=functools.partial(width_for_formatter, f), + ) + for f in self.formatters + ] + + self.app: Application[None] = Application( + min_redraw_interval=0.05, + layout=Layout( + HSplit( + [ + title_toolbar, + VSplit( + progress_controls, + height=lambda: D( + preferred=len(self.counters), max=len(self.counters) + ), + ), + Window(), + bottom_toolbar, + ] + ) + ), + style=self.style, + key_bindings=self.key_bindings, + refresh_interval=0.3, + color_depth=self.color_depth, + output=self.output, + input=self.input, + ) + + # Run application in different thread. + def run() -> None: + try: + self.app.run(pre_run=self._app_started.set) + except BaseException as e: + traceback.print_exc() + print(e) + + ctx: contextvars.Context = contextvars.copy_context() + + self._thread = threading.Thread(target=ctx.run, args=(run,)) + self._thread.start() + + return self + + def __exit__(self, *a: object) -> None: + # Wait for the app to be started. Make sure we don't quit earlier, + # otherwise `self.app.exit` won't terminate the app because + # `self.app.future` has not yet been set. + self._app_started.wait() + + # Quit UI application. + if self.app.is_running and self.app.loop is not None: + self.app.loop.call_soon_threadsafe(self.app.exit) + + if self._thread is not None: + self._thread.join() + + def __call__( + self, + data: Iterable[_T] | None = None, + label: AnyFormattedText = "", + remove_when_done: bool = False, + total: int | None = None, + ) -> ProgressBarCounter[_T]: + """ + Start a new counter. + + :param label: Title text or description for this progress. (This can be + formatted text as well). + :param remove_when_done: When `True`, hide this progress bar. + :param total: Specify the maximum value if it can't be calculated by + calling ``len``. + """ + counter = ProgressBarCounter( + self, data, label=label, remove_when_done=remove_when_done, total=total + ) + self.counters.append(counter) + return counter + + def invalidate(self) -> None: + self.app.invalidate() + + +class _ProgressControl(UIControl): + """ + User control for the progress bar. + """ + + def __init__( + self, + progress_bar: ProgressBar, + formatter: Formatter, + cancel_callback: Callable[[], None] | None, + ) -> None: + self.progress_bar = progress_bar + self.formatter = formatter + self._key_bindings = create_key_bindings(cancel_callback) + + def create_content(self, width: int, height: int) -> UIContent: + items: list[StyleAndTextTuples] = [] + + for pr in self.progress_bar.counters: + try: + text = self.formatter.format(self.progress_bar, pr, width) + except BaseException: + traceback.print_exc() + text = "ERROR" + + items.append(to_formatted_text(text)) + + def get_line(i: int) -> StyleAndTextTuples: + return items[i] + + return UIContent(get_line=get_line, line_count=len(items), show_cursor=False) + + def is_focusable(self) -> bool: + return True # Make sure that the key bindings work. + + def get_key_bindings(self) -> KeyBindings: + return self._key_bindings + + +_CounterItem = TypeVar("_CounterItem", covariant=True) + + +class ProgressBarCounter(Generic[_CounterItem]): + """ + An individual counter (A progress bar can have multiple counters). + """ + + def __init__( + self, + progress_bar: ProgressBar, + data: Iterable[_CounterItem] | None = None, + label: AnyFormattedText = "", + remove_when_done: bool = False, + total: int | None = None, + ) -> None: + self.start_time = datetime.datetime.now() + self.stop_time: datetime.datetime | None = None + self.progress_bar = progress_bar + self.data = data + self.items_completed = 0 + self.label = label + self.remove_when_done = remove_when_done + self._done = False + self.total: int | None + + if total is None: + try: + self.total = len(cast(Sized, data)) + except TypeError: + self.total = None # We don't know the total length. + else: + self.total = total + + def __iter__(self) -> Iterator[_CounterItem]: + if self.data is not None: + try: + for item in self.data: + yield item + self.item_completed() + + # Only done if we iterate to the very end. + self.done = True + finally: + # Ensure counter has stopped even if we did not iterate to the + # end (e.g. break or exceptions). + self.stopped = True + else: + raise NotImplementedError("No data defined to iterate over.") + + def item_completed(self) -> None: + """ + Start handling the next item. + + (Can be called manually in case we don't have a collection to loop through.) + """ + self.items_completed += 1 + self.progress_bar.invalidate() + + @property + def done(self) -> bool: + """Whether a counter has been completed. + + Done counter have been stopped (see stopped) and removed depending on + remove_when_done value. + + Contrast this with stopped. A stopped counter may be terminated before + 100% completion. A done counter has reached its 100% completion. + """ + return self._done + + @done.setter + def done(self, value: bool) -> None: + self._done = value + self.stopped = value + + if value and self.remove_when_done: + self.progress_bar.counters.remove(self) + + @property + def stopped(self) -> bool: + """Whether a counter has been stopped. + + Stopped counters no longer have increasing time_elapsed. This distinction is + also used to prevent the Bar formatter with unknown totals from continuing to run. + + A stopped counter (but not done) can be used to signal that a given counter has + encountered an error but allows other counters to continue + (e.g. download X of Y failed). Given how only done counters are removed + (see remove_when_done) this can help aggregate failures from a large number of + successes. + + Contrast this with done. A done counter has reached its 100% completion. + A stopped counter may be terminated before 100% completion. + """ + return self.stop_time is not None + + @stopped.setter + def stopped(self, value: bool) -> None: + if value: + # This counter has not already been stopped. + if not self.stop_time: + self.stop_time = datetime.datetime.now() + else: + # Clearing any previously set stop_time. + self.stop_time = None + + @property + def percentage(self) -> float: + if self.total is None: + return 0 + else: + return self.items_completed * 100 / max(self.total, 1) + + @property + def time_elapsed(self) -> datetime.timedelta: + """ + Return how much time has been elapsed since the start. + """ + if self.stop_time is None: + return datetime.datetime.now() - self.start_time + else: + return self.stop_time - self.start_time + + @property + def time_left(self) -> datetime.timedelta | None: + """ + Timedelta representing the time left. + """ + if self.total is None or not self.percentage: + return None + elif self.done or self.stopped: + return datetime.timedelta(0) + else: + return self.time_elapsed * (100 - self.percentage) / self.percentage diff --git a/src/prompt_toolkit/shortcuts/progress_bar/formatters.py b/src/prompt_toolkit/shortcuts/progress_bar/formatters.py new file mode 100644 index 0000000..dd0339c --- /dev/null +++ b/src/prompt_toolkit/shortcuts/progress_bar/formatters.py @@ -0,0 +1,429 @@ +""" +Formatter classes for the progress bar. +Each progress bar consists of a list of these formatters. +""" +from __future__ import annotations + +import datetime +import time +from abc import ABCMeta, abstractmethod +from typing import TYPE_CHECKING + +from prompt_toolkit.formatted_text import ( + HTML, + AnyFormattedText, + StyleAndTextTuples, + to_formatted_text, +) +from prompt_toolkit.formatted_text.utils import fragment_list_width +from prompt_toolkit.layout.dimension import AnyDimension, D +from prompt_toolkit.layout.utils import explode_text_fragments +from prompt_toolkit.utils import get_cwidth + +if TYPE_CHECKING: + from .base import ProgressBar, ProgressBarCounter + +__all__ = [ + "Formatter", + "Text", + "Label", + "Percentage", + "Bar", + "Progress", + "TimeElapsed", + "TimeLeft", + "IterationsPerSecond", + "SpinningWheel", + "Rainbow", + "create_default_formatters", +] + + +class Formatter(metaclass=ABCMeta): + """ + Base class for any formatter. + """ + + @abstractmethod + def format( + self, + progress_bar: ProgressBar, + progress: ProgressBarCounter[object], + width: int, + ) -> AnyFormattedText: + pass + + def get_width(self, progress_bar: ProgressBar) -> AnyDimension: + return D() + + +class Text(Formatter): + """ + Display plain text. + """ + + def __init__(self, text: AnyFormattedText, style: str = "") -> None: + self.text = to_formatted_text(text, style=style) + + def format( + self, + progress_bar: ProgressBar, + progress: ProgressBarCounter[object], + width: int, + ) -> AnyFormattedText: + return self.text + + def get_width(self, progress_bar: ProgressBar) -> AnyDimension: + return fragment_list_width(self.text) + + +class Label(Formatter): + """ + Display the name of the current task. + + :param width: If a `width` is given, use this width. Scroll the text if it + doesn't fit in this width. + :param suffix: String suffix to be added after the task name, e.g. ': '. + If no task name was given, no suffix will be added. + """ + + def __init__(self, width: AnyDimension = None, suffix: str = "") -> None: + self.width = width + self.suffix = suffix + + def _add_suffix(self, label: AnyFormattedText) -> StyleAndTextTuples: + label = to_formatted_text(label, style="class:label") + return label + [("", self.suffix)] + + def format( + self, + progress_bar: ProgressBar, + progress: ProgressBarCounter[object], + width: int, + ) -> AnyFormattedText: + label = self._add_suffix(progress.label) + cwidth = fragment_list_width(label) + + if cwidth > width: + # It doesn't fit -> scroll task name. + label = explode_text_fragments(label) + max_scroll = cwidth - width + current_scroll = int(time.time() * 3 % max_scroll) + label = label[current_scroll:] + + return label + + def get_width(self, progress_bar: ProgressBar) -> AnyDimension: + if self.width: + return self.width + + all_labels = [self._add_suffix(c.label) for c in progress_bar.counters] + if all_labels: + max_widths = max(fragment_list_width(l) for l in all_labels) + return D(preferred=max_widths, max=max_widths) + else: + return D() + + +class Percentage(Formatter): + """ + Display the progress as a percentage. + """ + + template = "<percentage>{percentage:>5}%</percentage>" + + def format( + self, + progress_bar: ProgressBar, + progress: ProgressBarCounter[object], + width: int, + ) -> AnyFormattedText: + return HTML(self.template).format(percentage=round(progress.percentage, 1)) + + def get_width(self, progress_bar: ProgressBar) -> AnyDimension: + return D.exact(6) + + +class Bar(Formatter): + """ + Display the progress bar itself. + """ + + template = "<bar>{start}<bar-a>{bar_a}</bar-a><bar-b>{bar_b}</bar-b><bar-c>{bar_c}</bar-c>{end}</bar>" + + def __init__( + self, + start: str = "[", + end: str = "]", + sym_a: str = "=", + sym_b: str = ">", + sym_c: str = " ", + unknown: str = "#", + ) -> None: + assert len(sym_a) == 1 and get_cwidth(sym_a) == 1 + assert len(sym_c) == 1 and get_cwidth(sym_c) == 1 + + self.start = start + self.end = end + self.sym_a = sym_a + self.sym_b = sym_b + self.sym_c = sym_c + self.unknown = unknown + + def format( + self, + progress_bar: ProgressBar, + progress: ProgressBarCounter[object], + width: int, + ) -> AnyFormattedText: + if progress.done or progress.total or progress.stopped: + sym_a, sym_b, sym_c = self.sym_a, self.sym_b, self.sym_c + + # Compute pb_a based on done, total, or stopped states. + if progress.done: + # 100% completed irrelevant of how much was actually marked as completed. + percent = 1.0 + else: + # Show percentage completed. + percent = progress.percentage / 100 + else: + # Total is unknown and bar is still running. + sym_a, sym_b, sym_c = self.sym_c, self.unknown, self.sym_c + + # Compute percent based on the time. + percent = time.time() * 20 % 100 / 100 + + # Subtract left, sym_b, and right. + width -= get_cwidth(self.start + sym_b + self.end) + + # Scale percent by width + pb_a = int(percent * width) + bar_a = sym_a * pb_a + bar_b = sym_b + bar_c = sym_c * (width - pb_a) + + return HTML(self.template).format( + start=self.start, end=self.end, bar_a=bar_a, bar_b=bar_b, bar_c=bar_c + ) + + def get_width(self, progress_bar: ProgressBar) -> AnyDimension: + return D(min=9) + + +class Progress(Formatter): + """ + Display the progress as text. E.g. "8/20" + """ + + template = "<current>{current:>3}</current>/<total>{total:>3}</total>" + + def format( + self, + progress_bar: ProgressBar, + progress: ProgressBarCounter[object], + width: int, + ) -> AnyFormattedText: + return HTML(self.template).format( + current=progress.items_completed, total=progress.total or "?" + ) + + def get_width(self, progress_bar: ProgressBar) -> AnyDimension: + all_lengths = [ + len("{:>3}".format(c.total or "?")) for c in progress_bar.counters + ] + all_lengths.append(1) + return D.exact(max(all_lengths) * 2 + 1) + + +def _format_timedelta(timedelta: datetime.timedelta) -> str: + """ + Return hh:mm:ss, or mm:ss if the amount of hours is zero. + """ + result = f"{timedelta}".split(".")[0] + if result.startswith("0:"): + result = result[2:] + return result + + +class TimeElapsed(Formatter): + """ + Display the elapsed time. + """ + + def format( + self, + progress_bar: ProgressBar, + progress: ProgressBarCounter[object], + width: int, + ) -> AnyFormattedText: + text = _format_timedelta(progress.time_elapsed).rjust(width) + return HTML("<time-elapsed>{time_elapsed}</time-elapsed>").format( + time_elapsed=text + ) + + def get_width(self, progress_bar: ProgressBar) -> AnyDimension: + all_values = [ + len(_format_timedelta(c.time_elapsed)) for c in progress_bar.counters + ] + if all_values: + return max(all_values) + return 0 + + +class TimeLeft(Formatter): + """ + Display the time left. + """ + + template = "<time-left>{time_left}</time-left>" + unknown = "?:??:??" + + def format( + self, + progress_bar: ProgressBar, + progress: ProgressBarCounter[object], + width: int, + ) -> AnyFormattedText: + time_left = progress.time_left + if time_left is not None: + formatted_time_left = _format_timedelta(time_left) + else: + formatted_time_left = self.unknown + + return HTML(self.template).format(time_left=formatted_time_left.rjust(width)) + + def get_width(self, progress_bar: ProgressBar) -> AnyDimension: + all_values = [ + len(_format_timedelta(c.time_left)) if c.time_left is not None else 7 + for c in progress_bar.counters + ] + if all_values: + return max(all_values) + return 0 + + +class IterationsPerSecond(Formatter): + """ + Display the iterations per second. + """ + + template = ( + "<iterations-per-second>{iterations_per_second:.2f}</iterations-per-second>" + ) + + def format( + self, + progress_bar: ProgressBar, + progress: ProgressBarCounter[object], + width: int, + ) -> AnyFormattedText: + value = progress.items_completed / progress.time_elapsed.total_seconds() + return HTML(self.template.format(iterations_per_second=value)) + + def get_width(self, progress_bar: ProgressBar) -> AnyDimension: + all_values = [ + len(f"{c.items_completed / c.time_elapsed.total_seconds():.2f}") + for c in progress_bar.counters + ] + if all_values: + return max(all_values) + return 0 + + +class SpinningWheel(Formatter): + """ + Display a spinning wheel. + """ + + characters = r"/-\|" + + def format( + self, + progress_bar: ProgressBar, + progress: ProgressBarCounter[object], + width: int, + ) -> AnyFormattedText: + index = int(time.time() * 3) % len(self.characters) + return HTML("<spinning-wheel>{0}</spinning-wheel>").format( + self.characters[index] + ) + + def get_width(self, progress_bar: ProgressBar) -> AnyDimension: + return D.exact(1) + + +def _hue_to_rgb(hue: float) -> tuple[int, int, int]: + """ + Take hue between 0 and 1, return (r, g, b). + """ + i = int(hue * 6.0) + f = (hue * 6.0) - i + + q = int(255 * (1.0 - f)) + t = int(255 * (1.0 - (1.0 - f))) + + i %= 6 + + return [ + (255, t, 0), + (q, 255, 0), + (0, 255, t), + (0, q, 255), + (t, 0, 255), + (255, 0, q), + ][i] + + +class Rainbow(Formatter): + """ + For the fun. Add rainbow colors to any of the other formatters. + """ + + colors = ["#%.2x%.2x%.2x" % _hue_to_rgb(h / 100.0) for h in range(0, 100)] + + def __init__(self, formatter: Formatter) -> None: + self.formatter = formatter + + def format( + self, + progress_bar: ProgressBar, + progress: ProgressBarCounter[object], + width: int, + ) -> AnyFormattedText: + # Get formatted text from nested formatter, and explode it in + # text/style tuples. + result = self.formatter.format(progress_bar, progress, width) + result = explode_text_fragments(to_formatted_text(result)) + + # Insert colors. + result2: StyleAndTextTuples = [] + shift = int(time.time() * 3) % len(self.colors) + + for i, (style, text, *_) in enumerate(result): + result2.append( + (style + " " + self.colors[(i + shift) % len(self.colors)], text) + ) + return result2 + + def get_width(self, progress_bar: ProgressBar) -> AnyDimension: + return self.formatter.get_width(progress_bar) + + +def create_default_formatters() -> list[Formatter]: + """ + Return the list of default formatters. + """ + return [ + Label(), + Text(" "), + Percentage(), + Text(" "), + Bar(), + Text(" "), + Progress(), + Text(" "), + Text("eta [", style="class:time-left"), + TimeLeft(), + Text("]", style="class:time-left"), + Text(" "), + ] diff --git a/src/prompt_toolkit/shortcuts/prompt.py b/src/prompt_toolkit/shortcuts/prompt.py new file mode 100644 index 0000000..7274b5f --- /dev/null +++ b/src/prompt_toolkit/shortcuts/prompt.py @@ -0,0 +1,1504 @@ +""" +Line editing functionality. +--------------------------- + +This provides a UI for a line input, similar to GNU Readline, libedit and +linenoise. + +Either call the `prompt` function for every line input. Or create an instance +of the :class:`.PromptSession` class and call the `prompt` method from that +class. In the second case, we'll have a 'session' that keeps all the state like +the history in between several calls. + +There is a lot of overlap between the arguments taken by the `prompt` function +and the `PromptSession` (like `completer`, `style`, etcetera). There we have +the freedom to decide which settings we want for the whole 'session', and which +we want for an individual `prompt`. + +Example:: + + # Simple `prompt` call. + result = prompt('Say something: ') + + # Using a 'session'. + s = PromptSession() + result = s.prompt('Say something: ') +""" +from __future__ import annotations + +from asyncio import get_running_loop +from contextlib import contextmanager +from enum import Enum +from functools import partial +from typing import TYPE_CHECKING, Callable, Generic, Iterator, TypeVar, Union, cast + +from prompt_toolkit.application import Application +from prompt_toolkit.application.current import get_app +from prompt_toolkit.auto_suggest import AutoSuggest, DynamicAutoSuggest +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.clipboard import Clipboard, DynamicClipboard, InMemoryClipboard +from prompt_toolkit.completion import Completer, DynamicCompleter, ThreadedCompleter +from prompt_toolkit.cursor_shapes import ( + AnyCursorShapeConfig, + CursorShapeConfig, + DynamicCursorShapeConfig, +) +from prompt_toolkit.document import Document +from prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER, EditingMode +from prompt_toolkit.eventloop import InputHook +from prompt_toolkit.filters import ( + Condition, + FilterOrBool, + has_arg, + has_focus, + is_done, + is_true, + renderer_height_is_known, + to_filter, +) +from prompt_toolkit.formatted_text import ( + AnyFormattedText, + StyleAndTextTuples, + fragment_list_to_text, + merge_formatted_text, + to_formatted_text, +) +from prompt_toolkit.history import History, InMemoryHistory +from prompt_toolkit.input.base import Input +from prompt_toolkit.key_binding.bindings.auto_suggest import load_auto_suggest_bindings +from prompt_toolkit.key_binding.bindings.completion import ( + display_completions_like_readline, +) +from prompt_toolkit.key_binding.bindings.open_in_editor import ( + load_open_in_editor_bindings, +) +from prompt_toolkit.key_binding.key_bindings import ( + ConditionalKeyBindings, + DynamicKeyBindings, + KeyBindings, + KeyBindingsBase, + merge_key_bindings, +) +from prompt_toolkit.key_binding.key_processor import KeyPressEvent +from prompt_toolkit.keys import Keys +from prompt_toolkit.layout import Float, FloatContainer, HSplit, Window +from prompt_toolkit.layout.containers import ConditionalContainer, WindowAlign +from prompt_toolkit.layout.controls import ( + BufferControl, + FormattedTextControl, + SearchBufferControl, +) +from prompt_toolkit.layout.dimension import Dimension +from prompt_toolkit.layout.layout import Layout +from prompt_toolkit.layout.menus import CompletionsMenu, MultiColumnCompletionsMenu +from prompt_toolkit.layout.processors import ( + AfterInput, + AppendAutoSuggestion, + ConditionalProcessor, + DisplayMultipleCursors, + DynamicProcessor, + HighlightIncrementalSearchProcessor, + HighlightSelectionProcessor, + PasswordProcessor, + Processor, + ReverseSearchProcessor, + merge_processors, +) +from prompt_toolkit.layout.utils import explode_text_fragments +from prompt_toolkit.lexers import DynamicLexer, Lexer +from prompt_toolkit.output import ColorDepth, DummyOutput, Output +from prompt_toolkit.styles import ( + BaseStyle, + ConditionalStyleTransformation, + DynamicStyle, + DynamicStyleTransformation, + StyleTransformation, + SwapLightAndDarkStyleTransformation, + merge_style_transformations, +) +from prompt_toolkit.utils import ( + get_cwidth, + is_dumb_terminal, + suspend_to_background_supported, + to_str, +) +from prompt_toolkit.validation import DynamicValidator, Validator +from prompt_toolkit.widgets.toolbars import ( + SearchToolbar, + SystemToolbar, + ValidationToolbar, +) + +if TYPE_CHECKING: + from prompt_toolkit.formatted_text.base import MagicFormattedText + +__all__ = [ + "PromptSession", + "prompt", + "confirm", + "create_confirm_session", # Used by '_display_completions_like_readline'. + "CompleteStyle", +] + +_StyleAndTextTuplesCallable = Callable[[], StyleAndTextTuples] +E = KeyPressEvent + + +def _split_multiline_prompt( + get_prompt_text: _StyleAndTextTuplesCallable, +) -> tuple[ + Callable[[], bool], _StyleAndTextTuplesCallable, _StyleAndTextTuplesCallable +]: + """ + Take a `get_prompt_text` function and return three new functions instead. + One that tells whether this prompt consists of multiple lines; one that + returns the fragments to be shown on the lines above the input; and another + one with the fragments to be shown at the first line of the input. + """ + + def has_before_fragments() -> bool: + for fragment, char, *_ in get_prompt_text(): + if "\n" in char: + return True + return False + + def before() -> StyleAndTextTuples: + result: StyleAndTextTuples = [] + found_nl = False + for fragment, char, *_ in reversed(explode_text_fragments(get_prompt_text())): + if found_nl: + result.insert(0, (fragment, char)) + elif char == "\n": + found_nl = True + return result + + def first_input_line() -> StyleAndTextTuples: + result: StyleAndTextTuples = [] + for fragment, char, *_ in reversed(explode_text_fragments(get_prompt_text())): + if char == "\n": + break + else: + result.insert(0, (fragment, char)) + return result + + return has_before_fragments, before, first_input_line + + +class _RPrompt(Window): + """ + The prompt that is displayed on the right side of the Window. + """ + + def __init__(self, text: AnyFormattedText) -> None: + super().__init__( + FormattedTextControl(text=text), + align=WindowAlign.RIGHT, + style="class:rprompt", + ) + + +class CompleteStyle(str, Enum): + """ + How to display autocompletions for the prompt. + """ + + value: str + + COLUMN = "COLUMN" + MULTI_COLUMN = "MULTI_COLUMN" + READLINE_LIKE = "READLINE_LIKE" + + +# Formatted text for the continuation prompt. It's the same like other +# formatted text, except that if it's a callable, it takes three arguments. +PromptContinuationText = Union[ + str, + "MagicFormattedText", + StyleAndTextTuples, + # (prompt_width, line_number, wrap_count) -> AnyFormattedText. + Callable[[int, int, int], AnyFormattedText], +] + +_T = TypeVar("_T") + + +class PromptSession(Generic[_T]): + """ + PromptSession for a prompt application, which can be used as a GNU Readline + replacement. + + This is a wrapper around a lot of ``prompt_toolkit`` functionality and can + be a replacement for `raw_input`. + + All parameters that expect "formatted text" can take either just plain text + (a unicode object), a list of ``(style_str, text)`` tuples or an HTML object. + + Example usage:: + + s = PromptSession(message='>') + text = s.prompt() + + :param message: Plain text or formatted text to be shown before the prompt. + This can also be a callable that returns formatted text. + :param multiline: `bool` or :class:`~prompt_toolkit.filters.Filter`. + When True, prefer a layout that is more adapted for multiline input. + Text after newlines is automatically indented, and search/arg input is + shown below the input, instead of replacing the prompt. + :param wrap_lines: `bool` or :class:`~prompt_toolkit.filters.Filter`. + When True (the default), automatically wrap long lines instead of + scrolling horizontally. + :param is_password: Show asterisks instead of the actual typed characters. + :param editing_mode: ``EditingMode.VI`` or ``EditingMode.EMACS``. + :param vi_mode: `bool`, if True, Identical to ``editing_mode=EditingMode.VI``. + :param complete_while_typing: `bool` or + :class:`~prompt_toolkit.filters.Filter`. Enable autocompletion while + typing. + :param validate_while_typing: `bool` or + :class:`~prompt_toolkit.filters.Filter`. Enable input validation while + typing. + :param enable_history_search: `bool` or + :class:`~prompt_toolkit.filters.Filter`. Enable up-arrow parting + string matching. + :param search_ignore_case: + :class:`~prompt_toolkit.filters.Filter`. Search case insensitive. + :param lexer: :class:`~prompt_toolkit.lexers.Lexer` to be used for the + syntax highlighting. + :param validator: :class:`~prompt_toolkit.validation.Validator` instance + for input validation. + :param completer: :class:`~prompt_toolkit.completion.Completer` instance + for input completion. + :param complete_in_thread: `bool` or + :class:`~prompt_toolkit.filters.Filter`. Run the completer code in a + background thread in order to avoid blocking the user interface. + For ``CompleteStyle.READLINE_LIKE``, this setting has no effect. There + we always run the completions in the main thread. + :param reserve_space_for_menu: Space to be reserved for displaying the menu. + (0 means that no space needs to be reserved.) + :param auto_suggest: :class:`~prompt_toolkit.auto_suggest.AutoSuggest` + instance for input suggestions. + :param style: :class:`.Style` instance for the color scheme. + :param include_default_pygments_style: `bool` or + :class:`~prompt_toolkit.filters.Filter`. Tell whether the default + styling for Pygments lexers has to be included. By default, this is + true, but it is recommended to be disabled if another Pygments style is + passed as the `style` argument, otherwise, two Pygments styles will be + merged. + :param style_transformation: + :class:`~prompt_toolkit.style.StyleTransformation` instance. + :param swap_light_and_dark_colors: `bool` or + :class:`~prompt_toolkit.filters.Filter`. When enabled, apply + :class:`~prompt_toolkit.style.SwapLightAndDarkStyleTransformation`. + This is useful for switching between dark and light terminal + backgrounds. + :param enable_system_prompt: `bool` or + :class:`~prompt_toolkit.filters.Filter`. Pressing Meta+'!' will show + a system prompt. + :param enable_suspend: `bool` or :class:`~prompt_toolkit.filters.Filter`. + Enable Control-Z style suspension. + :param enable_open_in_editor: `bool` or + :class:`~prompt_toolkit.filters.Filter`. Pressing 'v' in Vi mode or + C-X C-E in emacs mode will open an external editor. + :param history: :class:`~prompt_toolkit.history.History` instance. + :param clipboard: :class:`~prompt_toolkit.clipboard.Clipboard` instance. + (e.g. :class:`~prompt_toolkit.clipboard.InMemoryClipboard`) + :param rprompt: Text or formatted text to be displayed on the right side. + This can also be a callable that returns (formatted) text. + :param bottom_toolbar: Formatted text or callable which is supposed to + return formatted text. + :param prompt_continuation: Text that needs to be displayed for a multiline + prompt continuation. This can either be formatted text or a callable + that takes a `prompt_width`, `line_number` and `wrap_count` as input + and returns formatted text. When this is `None` (the default), then + `prompt_width` spaces will be used. + :param complete_style: ``CompleteStyle.COLUMN``, + ``CompleteStyle.MULTI_COLUMN`` or ``CompleteStyle.READLINE_LIKE``. + :param mouse_support: `bool` or :class:`~prompt_toolkit.filters.Filter` + to enable mouse support. + :param placeholder: Text to be displayed when no input has been given + yet. Unlike the `default` parameter, this won't be returned as part of + the output ever. This can be formatted text or a callable that returns + formatted text. + :param refresh_interval: (number; in seconds) When given, refresh the UI + every so many seconds. + :param input: `Input` object. (Note that the preferred way to change the + input/output is by creating an `AppSession`.) + :param output: `Output` object. + """ + + _fields = ( + "message", + "lexer", + "completer", + "complete_in_thread", + "is_password", + "editing_mode", + "key_bindings", + "is_password", + "bottom_toolbar", + "style", + "style_transformation", + "swap_light_and_dark_colors", + "color_depth", + "cursor", + "include_default_pygments_style", + "rprompt", + "multiline", + "prompt_continuation", + "wrap_lines", + "enable_history_search", + "search_ignore_case", + "complete_while_typing", + "validate_while_typing", + "complete_style", + "mouse_support", + "auto_suggest", + "clipboard", + "validator", + "refresh_interval", + "input_processors", + "placeholder", + "enable_system_prompt", + "enable_suspend", + "enable_open_in_editor", + "reserve_space_for_menu", + "tempfile_suffix", + "tempfile", + ) + + def __init__( + self, + message: AnyFormattedText = "", + *, + multiline: FilterOrBool = False, + wrap_lines: FilterOrBool = True, + is_password: FilterOrBool = False, + vi_mode: bool = False, + editing_mode: EditingMode = EditingMode.EMACS, + complete_while_typing: FilterOrBool = True, + validate_while_typing: FilterOrBool = True, + enable_history_search: FilterOrBool = False, + search_ignore_case: FilterOrBool = False, + lexer: Lexer | None = None, + enable_system_prompt: FilterOrBool = False, + enable_suspend: FilterOrBool = False, + enable_open_in_editor: FilterOrBool = False, + validator: Validator | None = None, + completer: Completer | None = None, + complete_in_thread: bool = False, + reserve_space_for_menu: int = 8, + complete_style: CompleteStyle = CompleteStyle.COLUMN, + auto_suggest: AutoSuggest | None = None, + style: BaseStyle | None = None, + style_transformation: StyleTransformation | None = None, + swap_light_and_dark_colors: FilterOrBool = False, + color_depth: ColorDepth | None = None, + cursor: AnyCursorShapeConfig = None, + include_default_pygments_style: FilterOrBool = True, + history: History | None = None, + clipboard: Clipboard | None = None, + prompt_continuation: PromptContinuationText | None = None, + rprompt: AnyFormattedText = None, + bottom_toolbar: AnyFormattedText = None, + mouse_support: FilterOrBool = False, + input_processors: list[Processor] | None = None, + placeholder: AnyFormattedText | None = None, + key_bindings: KeyBindingsBase | None = None, + erase_when_done: bool = False, + tempfile_suffix: str | Callable[[], str] | None = ".txt", + tempfile: str | Callable[[], str] | None = None, + refresh_interval: float = 0, + input: Input | None = None, + output: Output | None = None, + ) -> None: + history = history or InMemoryHistory() + clipboard = clipboard or InMemoryClipboard() + + # Ensure backwards-compatibility, when `vi_mode` is passed. + if vi_mode: + editing_mode = EditingMode.VI + + # Store all settings in this class. + self._input = input + self._output = output + + # Store attributes. + # (All except 'editing_mode'.) + self.message = message + self.lexer = lexer + self.completer = completer + self.complete_in_thread = complete_in_thread + self.is_password = is_password + self.key_bindings = key_bindings + self.bottom_toolbar = bottom_toolbar + self.style = style + self.style_transformation = style_transformation + self.swap_light_and_dark_colors = swap_light_and_dark_colors + self.color_depth = color_depth + self.cursor = cursor + self.include_default_pygments_style = include_default_pygments_style + self.rprompt = rprompt + self.multiline = multiline + self.prompt_continuation = prompt_continuation + self.wrap_lines = wrap_lines + self.enable_history_search = enable_history_search + self.search_ignore_case = search_ignore_case + self.complete_while_typing = complete_while_typing + self.validate_while_typing = validate_while_typing + self.complete_style = complete_style + self.mouse_support = mouse_support + self.auto_suggest = auto_suggest + self.clipboard = clipboard + self.validator = validator + self.refresh_interval = refresh_interval + self.input_processors = input_processors + self.placeholder = placeholder + self.enable_system_prompt = enable_system_prompt + self.enable_suspend = enable_suspend + self.enable_open_in_editor = enable_open_in_editor + self.reserve_space_for_menu = reserve_space_for_menu + self.tempfile_suffix = tempfile_suffix + self.tempfile = tempfile + + # Create buffers, layout and Application. + self.history = history + self.default_buffer = self._create_default_buffer() + self.search_buffer = self._create_search_buffer() + self.layout = self._create_layout() + self.app = self._create_application(editing_mode, erase_when_done) + + def _dyncond(self, attr_name: str) -> Condition: + """ + Dynamically take this setting from this 'PromptSession' class. + `attr_name` represents an attribute name of this class. Its value + can either be a boolean or a `Filter`. + + This returns something that can be used as either a `Filter` + or `Filter`. + """ + + @Condition + def dynamic() -> bool: + value = cast(FilterOrBool, getattr(self, attr_name)) + return to_filter(value)() + + return dynamic + + def _create_default_buffer(self) -> Buffer: + """ + Create and return the default input buffer. + """ + dyncond = self._dyncond + + # Create buffers list. + def accept(buff: Buffer) -> bool: + """Accept the content of the default buffer. This is called when + the validation succeeds.""" + cast(Application[str], get_app()).exit(result=buff.document.text) + return True # Keep text, we call 'reset' later on. + + return Buffer( + name=DEFAULT_BUFFER, + # Make sure that complete_while_typing is disabled when + # enable_history_search is enabled. (First convert to Filter, + # to avoid doing bitwise operations on bool objects.) + complete_while_typing=Condition( + lambda: is_true(self.complete_while_typing) + and not is_true(self.enable_history_search) + and not self.complete_style == CompleteStyle.READLINE_LIKE + ), + validate_while_typing=dyncond("validate_while_typing"), + enable_history_search=dyncond("enable_history_search"), + validator=DynamicValidator(lambda: self.validator), + completer=DynamicCompleter( + lambda: ThreadedCompleter(self.completer) + if self.complete_in_thread and self.completer + else self.completer + ), + history=self.history, + auto_suggest=DynamicAutoSuggest(lambda: self.auto_suggest), + accept_handler=accept, + tempfile_suffix=lambda: to_str(self.tempfile_suffix or ""), + tempfile=lambda: to_str(self.tempfile or ""), + ) + + def _create_search_buffer(self) -> Buffer: + return Buffer(name=SEARCH_BUFFER) + + def _create_layout(self) -> Layout: + """ + Create `Layout` for this prompt. + """ + dyncond = self._dyncond + + # Create functions that will dynamically split the prompt. (If we have + # a multiline prompt.) + ( + has_before_fragments, + get_prompt_text_1, + get_prompt_text_2, + ) = _split_multiline_prompt(self._get_prompt) + + default_buffer = self.default_buffer + search_buffer = self.search_buffer + + # Create processors list. + @Condition + def display_placeholder() -> bool: + return self.placeholder is not None and self.default_buffer.text == "" + + all_input_processors = [ + HighlightIncrementalSearchProcessor(), + HighlightSelectionProcessor(), + ConditionalProcessor( + AppendAutoSuggestion(), has_focus(default_buffer) & ~is_done + ), + ConditionalProcessor(PasswordProcessor(), dyncond("is_password")), + DisplayMultipleCursors(), + # Users can insert processors here. + DynamicProcessor(lambda: merge_processors(self.input_processors or [])), + ConditionalProcessor( + AfterInput(lambda: self.placeholder), + filter=display_placeholder, + ), + ] + + # Create bottom toolbars. + bottom_toolbar = ConditionalContainer( + Window( + FormattedTextControl( + lambda: self.bottom_toolbar, style="class:bottom-toolbar.text" + ), + style="class:bottom-toolbar", + dont_extend_height=True, + height=Dimension(min=1), + ), + filter=Condition(lambda: self.bottom_toolbar is not None) + & ~is_done + & renderer_height_is_known, + ) + + search_toolbar = SearchToolbar( + search_buffer, ignore_case=dyncond("search_ignore_case") + ) + + search_buffer_control = SearchBufferControl( + buffer=search_buffer, + input_processors=[ReverseSearchProcessor()], + ignore_case=dyncond("search_ignore_case"), + ) + + system_toolbar = SystemToolbar( + enable_global_bindings=dyncond("enable_system_prompt") + ) + + def get_search_buffer_control() -> SearchBufferControl: + "Return the UIControl to be focused when searching start." + if is_true(self.multiline): + return search_toolbar.control + else: + return search_buffer_control + + default_buffer_control = BufferControl( + buffer=default_buffer, + search_buffer_control=get_search_buffer_control, + input_processors=all_input_processors, + include_default_input_processors=False, + lexer=DynamicLexer(lambda: self.lexer), + preview_search=True, + ) + + default_buffer_window = Window( + default_buffer_control, + height=self._get_default_buffer_control_height, + get_line_prefix=partial( + self._get_line_prefix, get_prompt_text_2=get_prompt_text_2 + ), + wrap_lines=dyncond("wrap_lines"), + ) + + @Condition + def multi_column_complete_style() -> bool: + return self.complete_style == CompleteStyle.MULTI_COLUMN + + # Build the layout. + layout = HSplit( + [ + # The main input, with completion menus floating on top of it. + FloatContainer( + HSplit( + [ + ConditionalContainer( + Window( + FormattedTextControl(get_prompt_text_1), + dont_extend_height=True, + ), + Condition(has_before_fragments), + ), + ConditionalContainer( + default_buffer_window, + Condition( + lambda: get_app().layout.current_control + != search_buffer_control + ), + ), + ConditionalContainer( + Window(search_buffer_control), + Condition( + lambda: get_app().layout.current_control + == search_buffer_control + ), + ), + ] + ), + [ + # Completion menus. + # NOTE: Especially the multi-column menu needs to be + # transparent, because the shape is not always + # rectangular due to the meta-text below the menu. + Float( + xcursor=True, + ycursor=True, + transparent=True, + content=CompletionsMenu( + max_height=16, + scroll_offset=1, + extra_filter=has_focus(default_buffer) + & ~multi_column_complete_style, + ), + ), + Float( + xcursor=True, + ycursor=True, + transparent=True, + content=MultiColumnCompletionsMenu( + show_meta=True, + extra_filter=has_focus(default_buffer) + & multi_column_complete_style, + ), + ), + # The right prompt. + Float( + right=0, + top=0, + hide_when_covering_content=True, + content=_RPrompt(lambda: self.rprompt), + ), + ], + ), + ConditionalContainer(ValidationToolbar(), filter=~is_done), + ConditionalContainer( + system_toolbar, dyncond("enable_system_prompt") & ~is_done + ), + # In multiline mode, we use two toolbars for 'arg' and 'search'. + ConditionalContainer( + Window(FormattedTextControl(self._get_arg_text), height=1), + dyncond("multiline") & has_arg, + ), + ConditionalContainer(search_toolbar, dyncond("multiline") & ~is_done), + bottom_toolbar, + ] + ) + + return Layout(layout, default_buffer_window) + + def _create_application( + self, editing_mode: EditingMode, erase_when_done: bool + ) -> Application[_T]: + """ + Create the `Application` object. + """ + dyncond = self._dyncond + + # Default key bindings. + auto_suggest_bindings = load_auto_suggest_bindings() + open_in_editor_bindings = load_open_in_editor_bindings() + prompt_bindings = self._create_prompt_bindings() + + # Create application + application: Application[_T] = Application( + layout=self.layout, + style=DynamicStyle(lambda: self.style), + style_transformation=merge_style_transformations( + [ + DynamicStyleTransformation(lambda: self.style_transformation), + ConditionalStyleTransformation( + SwapLightAndDarkStyleTransformation(), + dyncond("swap_light_and_dark_colors"), + ), + ] + ), + include_default_pygments_style=dyncond("include_default_pygments_style"), + clipboard=DynamicClipboard(lambda: self.clipboard), + key_bindings=merge_key_bindings( + [ + merge_key_bindings( + [ + auto_suggest_bindings, + ConditionalKeyBindings( + open_in_editor_bindings, + dyncond("enable_open_in_editor") + & has_focus(DEFAULT_BUFFER), + ), + prompt_bindings, + ] + ), + DynamicKeyBindings(lambda: self.key_bindings), + ] + ), + mouse_support=dyncond("mouse_support"), + editing_mode=editing_mode, + erase_when_done=erase_when_done, + reverse_vi_search_direction=True, + color_depth=lambda: self.color_depth, + cursor=DynamicCursorShapeConfig(lambda: self.cursor), + refresh_interval=self.refresh_interval, + input=self._input, + output=self._output, + ) + + # During render time, make sure that we focus the right search control + # (if we are searching). - This could be useful if people make the + # 'multiline' property dynamic. + """ + def on_render(app): + multiline = is_true(self.multiline) + current_control = app.layout.current_control + + if multiline: + if current_control == search_buffer_control: + app.layout.current_control = search_toolbar.control + app.invalidate() + else: + if current_control == search_toolbar.control: + app.layout.current_control = search_buffer_control + app.invalidate() + + app.on_render += on_render + """ + + return application + + def _create_prompt_bindings(self) -> KeyBindings: + """ + Create the KeyBindings for a prompt application. + """ + kb = KeyBindings() + handle = kb.add + default_focused = has_focus(DEFAULT_BUFFER) + + @Condition + def do_accept() -> bool: + return not is_true(self.multiline) and self.app.layout.has_focus( + DEFAULT_BUFFER + ) + + @handle("enter", filter=do_accept & default_focused) + def _accept_input(event: E) -> None: + "Accept input when enter has been pressed." + self.default_buffer.validate_and_handle() + + @Condition + def readline_complete_style() -> bool: + return self.complete_style == CompleteStyle.READLINE_LIKE + + @handle("tab", filter=readline_complete_style & default_focused) + def _complete_like_readline(event: E) -> None: + "Display completions (like Readline)." + display_completions_like_readline(event) + + @handle("c-c", filter=default_focused) + @handle("<sigint>") + def _keyboard_interrupt(event: E) -> None: + "Abort when Control-C has been pressed." + event.app.exit(exception=KeyboardInterrupt, style="class:aborting") + + @Condition + def ctrl_d_condition() -> bool: + """Ctrl-D binding is only active when the default buffer is selected + and empty.""" + app = get_app() + return ( + app.current_buffer.name == DEFAULT_BUFFER + and not app.current_buffer.text + ) + + @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") + + suspend_supported = Condition(suspend_to_background_supported) + + @Condition + def enable_suspend() -> bool: + return to_filter(self.enable_suspend)() + + @handle("c-z", filter=suspend_supported & enable_suspend) + def _suspend(event: E) -> None: + """ + Suspend process to background. + """ + event.app.suspend_to_background() + + return kb + + def prompt( + self, + # When any of these arguments are passed, this value is overwritten + # in this PromptSession. + message: AnyFormattedText | None = None, + # `message` should go first, because people call it as + # positional argument. + *, + editing_mode: EditingMode | None = None, + refresh_interval: float | None = None, + vi_mode: bool | None = None, + lexer: Lexer | None = None, + completer: Completer | None = None, + complete_in_thread: bool | None = None, + is_password: bool | None = None, + key_bindings: KeyBindingsBase | None = None, + bottom_toolbar: AnyFormattedText | None = None, + style: BaseStyle | None = None, + color_depth: ColorDepth | None = None, + cursor: AnyCursorShapeConfig | None = None, + include_default_pygments_style: FilterOrBool | None = None, + style_transformation: StyleTransformation | None = None, + swap_light_and_dark_colors: FilterOrBool | None = None, + rprompt: AnyFormattedText | None = None, + multiline: FilterOrBool | None = None, + prompt_continuation: PromptContinuationText | None = None, + wrap_lines: FilterOrBool | None = None, + enable_history_search: FilterOrBool | None = None, + search_ignore_case: FilterOrBool | None = None, + complete_while_typing: FilterOrBool | None = None, + validate_while_typing: FilterOrBool | None = None, + complete_style: CompleteStyle | None = None, + auto_suggest: AutoSuggest | None = None, + validator: Validator | None = None, + clipboard: Clipboard | None = None, + mouse_support: FilterOrBool | None = None, + input_processors: list[Processor] | None = None, + placeholder: AnyFormattedText | None = None, + reserve_space_for_menu: int | None = None, + enable_system_prompt: FilterOrBool | None = None, + enable_suspend: FilterOrBool | None = None, + enable_open_in_editor: FilterOrBool | None = None, + tempfile_suffix: str | Callable[[], str] | None = None, + tempfile: str | Callable[[], str] | None = None, + # Following arguments are specific to the current `prompt()` call. + default: str | Document = "", + accept_default: bool = False, + pre_run: Callable[[], None] | None = None, + set_exception_handler: bool = True, + handle_sigint: bool = True, + in_thread: bool = False, + inputhook: InputHook | None = None, + ) -> _T: + """ + Display the prompt. + + The first set of arguments is a subset of the :class:`~.PromptSession` + class itself. For these, passing in ``None`` will keep the current + values that are active in the session. Passing in a value will set the + attribute for the session, which means that it applies to the current, + but also to the next prompts. + + Note that in order to erase a ``Completer``, ``Validator`` or + ``AutoSuggest``, you can't use ``None``. Instead pass in a + ``DummyCompleter``, ``DummyValidator`` or ``DummyAutoSuggest`` instance + respectively. For a ``Lexer`` you can pass in an empty ``SimpleLexer``. + + Additional arguments, specific for this prompt: + + :param default: The default input text to be shown. (This can be edited + by the user). + :param accept_default: When `True`, automatically accept the default + value without allowing the user to edit the input. + :param pre_run: Callable, called at the start of `Application.run`. + :param in_thread: Run the prompt in a background thread; block the + current thread. This avoids interference with an event loop in the + current thread. Like `Application.run(in_thread=True)`. + + This method will raise ``KeyboardInterrupt`` when control-c has been + pressed (for abort) and ``EOFError`` when control-d has been pressed + (for exit). + """ + # NOTE: We used to create a backup of the PromptSession attributes and + # restore them after exiting the prompt. This code has been + # removed, because it was confusing and didn't really serve a use + # case. (People were changing `Application.editing_mode` + # dynamically and surprised that it was reset after every call.) + + # NOTE 2: YES, this is a lot of repeation below... + # However, it is a very convenient for a user to accept all + # these parameters in this `prompt` method as well. We could + # use `locals()` and `setattr` to avoid the repetition, but + # then we loose the advantage of mypy and pyflakes to be able + # to verify the code. + if message is not None: + self.message = message + if editing_mode is not None: + self.editing_mode = editing_mode + if refresh_interval is not None: + self.refresh_interval = refresh_interval + if vi_mode: + self.editing_mode = EditingMode.VI + if lexer is not None: + self.lexer = lexer + if completer is not None: + self.completer = completer + if complete_in_thread is not None: + self.complete_in_thread = complete_in_thread + if is_password is not None: + self.is_password = is_password + if key_bindings is not None: + self.key_bindings = key_bindings + if bottom_toolbar is not None: + self.bottom_toolbar = bottom_toolbar + if style is not None: + self.style = style + if color_depth is not None: + self.color_depth = color_depth + if cursor is not None: + self.cursor = cursor + if include_default_pygments_style is not None: + self.include_default_pygments_style = include_default_pygments_style + if style_transformation is not None: + self.style_transformation = style_transformation + if swap_light_and_dark_colors is not None: + self.swap_light_and_dark_colors = swap_light_and_dark_colors + if rprompt is not None: + self.rprompt = rprompt + if multiline is not None: + self.multiline = multiline + if prompt_continuation is not None: + self.prompt_continuation = prompt_continuation + if wrap_lines is not None: + self.wrap_lines = wrap_lines + if enable_history_search is not None: + self.enable_history_search = enable_history_search + if search_ignore_case is not None: + self.search_ignore_case = search_ignore_case + if complete_while_typing is not None: + self.complete_while_typing = complete_while_typing + if validate_while_typing is not None: + self.validate_while_typing = validate_while_typing + if complete_style is not None: + self.complete_style = complete_style + if auto_suggest is not None: + self.auto_suggest = auto_suggest + if validator is not None: + self.validator = validator + if clipboard is not None: + self.clipboard = clipboard + if mouse_support is not None: + self.mouse_support = mouse_support + if input_processors is not None: + self.input_processors = input_processors + if placeholder is not None: + self.placeholder = placeholder + if reserve_space_for_menu is not None: + self.reserve_space_for_menu = reserve_space_for_menu + if enable_system_prompt is not None: + self.enable_system_prompt = enable_system_prompt + if enable_suspend is not None: + self.enable_suspend = enable_suspend + if enable_open_in_editor is not None: + self.enable_open_in_editor = enable_open_in_editor + if tempfile_suffix is not None: + self.tempfile_suffix = tempfile_suffix + if tempfile is not None: + self.tempfile = tempfile + + self._add_pre_run_callables(pre_run, accept_default) + self.default_buffer.reset( + default if isinstance(default, Document) else Document(default) + ) + self.app.refresh_interval = self.refresh_interval # This is not reactive. + + # If we are using the default output, and have a dumb terminal. Use the + # dumb prompt. + if self._output is None and is_dumb_terminal(): + with self._dumb_prompt(self.message) as dump_app: + return dump_app.run(in_thread=in_thread, handle_sigint=handle_sigint) + + return self.app.run( + set_exception_handler=set_exception_handler, + in_thread=in_thread, + handle_sigint=handle_sigint, + inputhook=inputhook, + ) + + @contextmanager + def _dumb_prompt(self, message: AnyFormattedText = "") -> Iterator[Application[_T]]: + """ + Create prompt `Application` for prompt function for dumb terminals. + + Dumb terminals have minimum rendering capabilities. We can only print + text to the screen. We can't use colors, and we can't do cursor + movements. The Emacs inferior shell is an example of a dumb terminal. + + We will show the prompt, and wait for the input. We still handle arrow + keys, and all custom key bindings, but we don't really render the + cursor movements. Instead we only print the typed character that's + right before the cursor. + """ + # Send prompt to output. + self.output.write(fragment_list_to_text(to_formatted_text(self.message))) + self.output.flush() + + # Key bindings for the dumb prompt: mostly the same as the full prompt. + key_bindings: KeyBindingsBase = self._create_prompt_bindings() + if self.key_bindings: + key_bindings = merge_key_bindings([self.key_bindings, key_bindings]) + + # Create and run application. + application = cast( + Application[_T], + Application( + input=self.input, + output=DummyOutput(), + layout=self.layout, + key_bindings=key_bindings, + ), + ) + + def on_text_changed(_: object) -> None: + self.output.write(self.default_buffer.document.text_before_cursor[-1:]) + self.output.flush() + + self.default_buffer.on_text_changed += on_text_changed + + try: + yield application + finally: + # Render line ending. + self.output.write("\r\n") + self.output.flush() + + self.default_buffer.on_text_changed -= on_text_changed + + async def prompt_async( + self, + # When any of these arguments are passed, this value is overwritten + # in this PromptSession. + message: AnyFormattedText | None = None, + # `message` should go first, because people call it as + # positional argument. + *, + editing_mode: EditingMode | None = None, + refresh_interval: float | None = None, + vi_mode: bool | None = None, + lexer: Lexer | None = None, + completer: Completer | None = None, + complete_in_thread: bool | None = None, + is_password: bool | None = None, + key_bindings: KeyBindingsBase | None = None, + bottom_toolbar: AnyFormattedText | None = None, + style: BaseStyle | None = None, + color_depth: ColorDepth | None = None, + cursor: CursorShapeConfig | None = None, + include_default_pygments_style: FilterOrBool | None = None, + style_transformation: StyleTransformation | None = None, + swap_light_and_dark_colors: FilterOrBool | None = None, + rprompt: AnyFormattedText | None = None, + multiline: FilterOrBool | None = None, + prompt_continuation: PromptContinuationText | None = None, + wrap_lines: FilterOrBool | None = None, + enable_history_search: FilterOrBool | None = None, + search_ignore_case: FilterOrBool | None = None, + complete_while_typing: FilterOrBool | None = None, + validate_while_typing: FilterOrBool | None = None, + complete_style: CompleteStyle | None = None, + auto_suggest: AutoSuggest | None = None, + validator: Validator | None = None, + clipboard: Clipboard | None = None, + mouse_support: FilterOrBool | None = None, + input_processors: list[Processor] | None = None, + placeholder: AnyFormattedText | None = None, + reserve_space_for_menu: int | None = None, + enable_system_prompt: FilterOrBool | None = None, + enable_suspend: FilterOrBool | None = None, + enable_open_in_editor: FilterOrBool | None = None, + tempfile_suffix: str | Callable[[], str] | None = None, + tempfile: str | Callable[[], str] | None = None, + # Following arguments are specific to the current `prompt()` call. + default: str | Document = "", + accept_default: bool = False, + pre_run: Callable[[], None] | None = None, + set_exception_handler: bool = True, + handle_sigint: bool = True, + ) -> _T: + if message is not None: + self.message = message + if editing_mode is not None: + self.editing_mode = editing_mode + if refresh_interval is not None: + self.refresh_interval = refresh_interval + if vi_mode: + self.editing_mode = EditingMode.VI + if lexer is not None: + self.lexer = lexer + if completer is not None: + self.completer = completer + if complete_in_thread is not None: + self.complete_in_thread = complete_in_thread + if is_password is not None: + self.is_password = is_password + if key_bindings is not None: + self.key_bindings = key_bindings + if bottom_toolbar is not None: + self.bottom_toolbar = bottom_toolbar + if style is not None: + self.style = style + if color_depth is not None: + self.color_depth = color_depth + if cursor is not None: + self.cursor = cursor + if include_default_pygments_style is not None: + self.include_default_pygments_style = include_default_pygments_style + if style_transformation is not None: + self.style_transformation = style_transformation + if swap_light_and_dark_colors is not None: + self.swap_light_and_dark_colors = swap_light_and_dark_colors + if rprompt is not None: + self.rprompt = rprompt + if multiline is not None: + self.multiline = multiline + if prompt_continuation is not None: + self.prompt_continuation = prompt_continuation + if wrap_lines is not None: + self.wrap_lines = wrap_lines + if enable_history_search is not None: + self.enable_history_search = enable_history_search + if search_ignore_case is not None: + self.search_ignore_case = search_ignore_case + if complete_while_typing is not None: + self.complete_while_typing = complete_while_typing + if validate_while_typing is not None: + self.validate_while_typing = validate_while_typing + if complete_style is not None: + self.complete_style = complete_style + if auto_suggest is not None: + self.auto_suggest = auto_suggest + if validator is not None: + self.validator = validator + if clipboard is not None: + self.clipboard = clipboard + if mouse_support is not None: + self.mouse_support = mouse_support + if input_processors is not None: + self.input_processors = input_processors + if placeholder is not None: + self.placeholder = placeholder + if reserve_space_for_menu is not None: + self.reserve_space_for_menu = reserve_space_for_menu + if enable_system_prompt is not None: + self.enable_system_prompt = enable_system_prompt + if enable_suspend is not None: + self.enable_suspend = enable_suspend + if enable_open_in_editor is not None: + self.enable_open_in_editor = enable_open_in_editor + if tempfile_suffix is not None: + self.tempfile_suffix = tempfile_suffix + if tempfile is not None: + self.tempfile = tempfile + + self._add_pre_run_callables(pre_run, accept_default) + self.default_buffer.reset( + default if isinstance(default, Document) else Document(default) + ) + self.app.refresh_interval = self.refresh_interval # This is not reactive. + + # If we are using the default output, and have a dumb terminal. Use the + # dumb prompt. + if self._output is None and is_dumb_terminal(): + with self._dumb_prompt(self.message) as dump_app: + return await dump_app.run_async(handle_sigint=handle_sigint) + + return await self.app.run_async( + set_exception_handler=set_exception_handler, handle_sigint=handle_sigint + ) + + def _add_pre_run_callables( + self, pre_run: Callable[[], None] | None, accept_default: bool + ) -> None: + def pre_run2() -> None: + if pre_run: + pre_run() + + if accept_default: + # Validate and handle input. We use `call_from_executor` in + # order to run it "soon" (during the next iteration of the + # event loop), instead of right now. Otherwise, it won't + # display the default value. + get_running_loop().call_soon(self.default_buffer.validate_and_handle) + + self.app.pre_run_callables.append(pre_run2) + + @property + def editing_mode(self) -> EditingMode: + return self.app.editing_mode + + @editing_mode.setter + def editing_mode(self, value: EditingMode) -> None: + self.app.editing_mode = value + + def _get_default_buffer_control_height(self) -> Dimension: + # If there is an autocompletion menu to be shown, make sure that our + # layout has at least a minimal height in order to display it. + if ( + self.completer is not None + and self.complete_style != CompleteStyle.READLINE_LIKE + ): + space = self.reserve_space_for_menu + else: + space = 0 + + if space and not get_app().is_done: + buff = self.default_buffer + + # Reserve the space, either when there are completions, or when + # `complete_while_typing` is true and we expect completions very + # soon. + if buff.complete_while_typing() or buff.complete_state is not None: + return Dimension(min=space) + + return Dimension() + + def _get_prompt(self) -> StyleAndTextTuples: + return to_formatted_text(self.message, style="class:prompt") + + def _get_continuation( + self, width: int, line_number: int, wrap_count: int + ) -> StyleAndTextTuples: + """ + Insert the prompt continuation. + + :param width: The width that was used for the prompt. (more or less can + be used.) + :param line_number: + :param wrap_count: Amount of times that the line has been wrapped. + """ + prompt_continuation = self.prompt_continuation + + if callable(prompt_continuation): + continuation: AnyFormattedText = prompt_continuation( + width, line_number, wrap_count + ) + else: + continuation = prompt_continuation + + # When the continuation prompt is not given, choose the same width as + # the actual prompt. + if continuation is None and is_true(self.multiline): + continuation = " " * width + + return to_formatted_text(continuation, style="class:prompt-continuation") + + def _get_line_prefix( + self, + line_number: int, + wrap_count: int, + get_prompt_text_2: _StyleAndTextTuplesCallable, + ) -> StyleAndTextTuples: + """ + Return whatever needs to be inserted before every line. + (the prompt, or a line continuation.) + """ + # First line: display the "arg" or the prompt. + if line_number == 0 and wrap_count == 0: + if not is_true(self.multiline) and get_app().key_processor.arg is not None: + return self._inline_arg() + else: + return get_prompt_text_2() + + # For the next lines, display the appropriate continuation. + prompt_width = get_cwidth(fragment_list_to_text(get_prompt_text_2())) + return self._get_continuation(prompt_width, line_number, wrap_count) + + def _get_arg_text(self) -> StyleAndTextTuples: + "'arg' toolbar, for in multiline mode." + arg = self.app.key_processor.arg + if arg is None: + # Should not happen because of the `has_arg` filter in the layout. + return [] + + if arg == "-": + arg = "-1" + + return [("class:arg-toolbar", "Repeat: "), ("class:arg-toolbar.text", arg)] + + def _inline_arg(self) -> StyleAndTextTuples: + "'arg' prefix, for in single line mode." + app = get_app() + if app.key_processor.arg is None: + return [] + else: + arg = app.key_processor.arg + + return [ + ("class:prompt.arg", "(arg: "), + ("class:prompt.arg.text", str(arg)), + ("class:prompt.arg", ") "), + ] + + # Expose the Input and Output objects as attributes, mainly for + # backward-compatibility. + + @property + def input(self) -> Input: + return self.app.input + + @property + def output(self) -> Output: + return self.app.output + + +def prompt( + message: AnyFormattedText | None = None, + *, + history: History | None = None, + editing_mode: EditingMode | None = None, + refresh_interval: float | None = None, + vi_mode: bool | None = None, + lexer: Lexer | None = None, + completer: Completer | None = None, + complete_in_thread: bool | None = None, + is_password: bool | None = None, + key_bindings: KeyBindingsBase | None = None, + bottom_toolbar: AnyFormattedText | None = None, + style: BaseStyle | None = None, + color_depth: ColorDepth | None = None, + cursor: AnyCursorShapeConfig = None, + include_default_pygments_style: FilterOrBool | None = None, + style_transformation: StyleTransformation | None = None, + swap_light_and_dark_colors: FilterOrBool | None = None, + rprompt: AnyFormattedText | None = None, + multiline: FilterOrBool | None = None, + prompt_continuation: PromptContinuationText | None = None, + wrap_lines: FilterOrBool | None = None, + enable_history_search: FilterOrBool | None = None, + search_ignore_case: FilterOrBool | None = None, + complete_while_typing: FilterOrBool | None = None, + validate_while_typing: FilterOrBool | None = None, + complete_style: CompleteStyle | None = None, + auto_suggest: AutoSuggest | None = None, + validator: Validator | None = None, + clipboard: Clipboard | None = None, + mouse_support: FilterOrBool | None = None, + input_processors: list[Processor] | None = None, + placeholder: AnyFormattedText | None = None, + reserve_space_for_menu: int | None = None, + enable_system_prompt: FilterOrBool | None = None, + enable_suspend: FilterOrBool | None = None, + enable_open_in_editor: FilterOrBool | None = None, + tempfile_suffix: str | Callable[[], str] | None = None, + tempfile: str | Callable[[], str] | None = None, + # Following arguments are specific to the current `prompt()` call. + default: str = "", + accept_default: bool = False, + pre_run: Callable[[], None] | None = None, + set_exception_handler: bool = True, + handle_sigint: bool = True, + in_thread: bool = False, + inputhook: InputHook | None = None, +) -> str: + """ + The global `prompt` function. This will create a new `PromptSession` + instance for every call. + """ + # The history is the only attribute that has to be passed to the + # `PromptSession`, it can't be passed into the `prompt()` method. + session: PromptSession[str] = PromptSession(history=history) + + return session.prompt( + message, + editing_mode=editing_mode, + refresh_interval=refresh_interval, + vi_mode=vi_mode, + lexer=lexer, + completer=completer, + complete_in_thread=complete_in_thread, + is_password=is_password, + key_bindings=key_bindings, + bottom_toolbar=bottom_toolbar, + style=style, + color_depth=color_depth, + cursor=cursor, + include_default_pygments_style=include_default_pygments_style, + style_transformation=style_transformation, + swap_light_and_dark_colors=swap_light_and_dark_colors, + rprompt=rprompt, + multiline=multiline, + prompt_continuation=prompt_continuation, + wrap_lines=wrap_lines, + enable_history_search=enable_history_search, + search_ignore_case=search_ignore_case, + complete_while_typing=complete_while_typing, + validate_while_typing=validate_while_typing, + complete_style=complete_style, + auto_suggest=auto_suggest, + validator=validator, + clipboard=clipboard, + mouse_support=mouse_support, + input_processors=input_processors, + placeholder=placeholder, + reserve_space_for_menu=reserve_space_for_menu, + enable_system_prompt=enable_system_prompt, + enable_suspend=enable_suspend, + enable_open_in_editor=enable_open_in_editor, + tempfile_suffix=tempfile_suffix, + tempfile=tempfile, + default=default, + accept_default=accept_default, + pre_run=pre_run, + set_exception_handler=set_exception_handler, + handle_sigint=handle_sigint, + in_thread=in_thread, + inputhook=inputhook, + ) + + +prompt.__doc__ = PromptSession.prompt.__doc__ + + +def create_confirm_session( + message: str, suffix: str = " (y/n) " +) -> PromptSession[bool]: + """ + Create a `PromptSession` object for the 'confirm' function. + """ + bindings = KeyBindings() + + @bindings.add("y") + @bindings.add("Y") + def yes(event: E) -> None: + session.default_buffer.text = "y" + event.app.exit(result=True) + + @bindings.add("n") + @bindings.add("N") + def no(event: E) -> None: + session.default_buffer.text = "n" + event.app.exit(result=False) + + @bindings.add(Keys.Any) + def _(event: E) -> None: + "Disallow inserting other text." + pass + + complete_message = merge_formatted_text([message, suffix]) + session: PromptSession[bool] = PromptSession( + complete_message, key_bindings=bindings + ) + return session + + +def confirm(message: str = "Confirm?", suffix: str = " (y/n) ") -> bool: + """ + Display a confirmation prompt that returns True/False. + """ + session = create_confirm_session(message, suffix) + return session.prompt() diff --git a/src/prompt_toolkit/shortcuts/utils.py b/src/prompt_toolkit/shortcuts/utils.py new file mode 100644 index 0000000..abf4fd2 --- /dev/null +++ b/src/prompt_toolkit/shortcuts/utils.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +from asyncio.events import AbstractEventLoop +from typing import TYPE_CHECKING, Any, TextIO + +from prompt_toolkit.application import Application +from prompt_toolkit.application.current import get_app_or_none, get_app_session +from prompt_toolkit.application.run_in_terminal import run_in_terminal +from prompt_toolkit.formatted_text import ( + FormattedText, + StyleAndTextTuples, + to_formatted_text, +) +from prompt_toolkit.input import DummyInput +from prompt_toolkit.layout import Layout +from prompt_toolkit.output import ColorDepth, Output +from prompt_toolkit.output.defaults import create_output +from prompt_toolkit.renderer import ( + print_formatted_text as renderer_print_formatted_text, +) +from prompt_toolkit.styles import ( + BaseStyle, + StyleTransformation, + default_pygments_style, + default_ui_style, + merge_styles, +) + +if TYPE_CHECKING: + from prompt_toolkit.layout.containers import AnyContainer + +__all__ = [ + "print_formatted_text", + "print_container", + "clear", + "set_title", + "clear_title", +] + + +def print_formatted_text( + *values: Any, + sep: str = " ", + end: str = "\n", + file: TextIO | None = None, + flush: bool = False, + style: BaseStyle | None = None, + output: Output | None = None, + color_depth: ColorDepth | None = None, + style_transformation: StyleTransformation | None = None, + include_default_pygments_style: bool = True, +) -> None: + """ + :: + + print_formatted_text(*values, sep=' ', end='\\n', file=None, flush=False, style=None, output=None) + + Print text to stdout. This is supposed to be compatible with Python's print + function, but supports printing of formatted text. You can pass a + :class:`~prompt_toolkit.formatted_text.FormattedText`, + :class:`~prompt_toolkit.formatted_text.HTML` or + :class:`~prompt_toolkit.formatted_text.ANSI` object to print formatted + text. + + * Print HTML as follows:: + + print_formatted_text(HTML('<i>Some italic text</i> <ansired>This is red!</ansired>')) + + style = Style.from_dict({ + 'hello': '#ff0066', + 'world': '#884444 italic', + }) + print_formatted_text(HTML('<hello>Hello</hello> <world>world</world>!'), style=style) + + * Print a list of (style_str, text) tuples in the given style to the + output. E.g.:: + + style = Style.from_dict({ + 'hello': '#ff0066', + 'world': '#884444 italic', + }) + fragments = FormattedText([ + ('class:hello', 'Hello'), + ('class:world', 'World'), + ]) + print_formatted_text(fragments, style=style) + + If you want to print a list of Pygments tokens, wrap it in + :class:`~prompt_toolkit.formatted_text.PygmentsTokens` to do the + conversion. + + If a prompt_toolkit `Application` is currently running, this will always + print above the application or prompt (similar to `patch_stdout`). So, + `print_formatted_text` will erase the current application, print the text, + and render the application again. + + :param values: Any kind of printable object, or formatted string. + :param sep: String inserted between values, default a space. + :param end: String appended after the last value, default a newline. + :param style: :class:`.Style` instance for the color scheme. + :param include_default_pygments_style: `bool`. Include the default Pygments + style when set to `True` (the default). + """ + assert not (output and file) + + # Create Output object. + if output is None: + if file: + output = create_output(stdout=file) + else: + output = get_app_session().output + + assert isinstance(output, Output) + + # Get color depth. + color_depth = color_depth or output.get_default_color_depth() + + # Merges values. + def to_text(val: Any) -> StyleAndTextTuples: + # Normal lists which are not instances of `FormattedText` are + # considered plain text. + if isinstance(val, list) and not isinstance(val, FormattedText): + return to_formatted_text(f"{val}") + return to_formatted_text(val, auto_convert=True) + + fragments = [] + for i, value in enumerate(values): + fragments.extend(to_text(value)) + + if sep and i != len(values) - 1: + fragments.extend(to_text(sep)) + + fragments.extend(to_text(end)) + + # Print output. + def render() -> None: + assert isinstance(output, Output) + + renderer_print_formatted_text( + output, + fragments, + _create_merged_style( + style, include_default_pygments_style=include_default_pygments_style + ), + color_depth=color_depth, + style_transformation=style_transformation, + ) + + # Flush the output stream. + if flush: + output.flush() + + # If an application is running, print above the app. This does not require + # `patch_stdout`. + loop: AbstractEventLoop | None = None + + app = get_app_or_none() + if app is not None: + loop = app.loop + + if loop is not None: + loop.call_soon_threadsafe(lambda: run_in_terminal(render)) + else: + render() + + +def print_container( + container: AnyContainer, + file: TextIO | None = None, + style: BaseStyle | None = None, + include_default_pygments_style: bool = True, +) -> None: + """ + Print any layout to the output in a non-interactive way. + + Example usage:: + + from prompt_toolkit.widgets import Frame, TextArea + print_container( + Frame(TextArea(text='Hello world!'))) + """ + if file: + output = create_output(stdout=file) + else: + output = get_app_session().output + + app: Application[None] = Application( + layout=Layout(container=container), + output=output, + # `DummyInput` will cause the application to terminate immediately. + input=DummyInput(), + style=_create_merged_style( + style, include_default_pygments_style=include_default_pygments_style + ), + ) + try: + app.run(in_thread=True) + except EOFError: + pass + + +def _create_merged_style( + style: BaseStyle | None, include_default_pygments_style: bool +) -> BaseStyle: + """ + Merge user defined style with built-in style. + """ + styles = [default_ui_style()] + if include_default_pygments_style: + styles.append(default_pygments_style()) + if style: + styles.append(style) + + return merge_styles(styles) + + +def clear() -> None: + """ + Clear the screen. + """ + output = get_app_session().output + output.erase_screen() + output.cursor_goto(0, 0) + output.flush() + + +def set_title(text: str) -> None: + """ + Set the terminal title. + """ + output = get_app_session().output + output.set_title(text) + + +def clear_title() -> None: + """ + Erase the current title. + """ + set_title("") diff --git a/src/prompt_toolkit/styles/__init__.py b/src/prompt_toolkit/styles/__init__.py new file mode 100644 index 0000000..23f61bb --- /dev/null +++ b/src/prompt_toolkit/styles/__init__.py @@ -0,0 +1,66 @@ +""" +Styling for prompt_toolkit applications. +""" +from __future__ import annotations + +from .base import ( + ANSI_COLOR_NAMES, + DEFAULT_ATTRS, + Attrs, + BaseStyle, + DummyStyle, + DynamicStyle, +) +from .defaults import default_pygments_style, default_ui_style +from .named_colors import NAMED_COLORS +from .pygments import ( + pygments_token_to_classname, + style_from_pygments_cls, + style_from_pygments_dict, +) +from .style import Priority, Style, merge_styles, parse_color +from .style_transformation import ( + AdjustBrightnessStyleTransformation, + ConditionalStyleTransformation, + DummyStyleTransformation, + DynamicStyleTransformation, + ReverseStyleTransformation, + SetDefaultColorStyleTransformation, + StyleTransformation, + SwapLightAndDarkStyleTransformation, + merge_style_transformations, +) + +__all__ = [ + # Base. + "Attrs", + "DEFAULT_ATTRS", + "ANSI_COLOR_NAMES", + "BaseStyle", + "DummyStyle", + "DynamicStyle", + # Defaults. + "default_ui_style", + "default_pygments_style", + # Style. + "Style", + "Priority", + "merge_styles", + "parse_color", + # Style transformation. + "StyleTransformation", + "SwapLightAndDarkStyleTransformation", + "ReverseStyleTransformation", + "SetDefaultColorStyleTransformation", + "AdjustBrightnessStyleTransformation", + "DummyStyleTransformation", + "ConditionalStyleTransformation", + "DynamicStyleTransformation", + "merge_style_transformations", + # Pygments. + "style_from_pygments_cls", + "style_from_pygments_dict", + "pygments_token_to_classname", + # Named colors. + "NAMED_COLORS", +] diff --git a/src/prompt_toolkit/styles/base.py b/src/prompt_toolkit/styles/base.py new file mode 100644 index 0000000..b50f3b0 --- /dev/null +++ b/src/prompt_toolkit/styles/base.py @@ -0,0 +1,183 @@ +""" +The base classes for the styling. +""" +from __future__ import annotations + +from abc import ABCMeta, abstractmethod, abstractproperty +from typing import Callable, Hashable, NamedTuple + +__all__ = [ + "Attrs", + "DEFAULT_ATTRS", + "ANSI_COLOR_NAMES", + "ANSI_COLOR_NAMES_ALIASES", + "BaseStyle", + "DummyStyle", + "DynamicStyle", +] + + +#: Style attributes. +class Attrs(NamedTuple): + color: str | None + bgcolor: str | None + bold: bool | None + underline: bool | None + strike: bool | None + italic: bool | None + blink: bool | None + reverse: bool | None + hidden: bool | None + + +""" +:param color: Hexadecimal string. E.g. '000000' or Ansi color name: e.g. 'ansiblue' +:param bgcolor: Hexadecimal string. E.g. 'ffffff' or Ansi color name: e.g. 'ansired' +:param bold: Boolean +:param underline: Boolean +:param strike: Boolean +:param italic: Boolean +:param blink: Boolean +:param reverse: Boolean +:param hidden: Boolean +""" + +#: The default `Attrs`. +DEFAULT_ATTRS = Attrs( + color="", + bgcolor="", + bold=False, + underline=False, + strike=False, + italic=False, + blink=False, + reverse=False, + hidden=False, +) + + +#: ``Attrs.bgcolor/fgcolor`` can be in either 'ffffff' format, or can be any of +#: the following in case we want to take colors from the 8/16 color palette. +#: Usually, in that case, the terminal application allows to configure the RGB +#: values for these names. +#: ISO 6429 colors +ANSI_COLOR_NAMES = [ + "ansidefault", + # Low intensity, dark. (One or two components 0x80, the other 0x00.) + "ansiblack", + "ansired", + "ansigreen", + "ansiyellow", + "ansiblue", + "ansimagenta", + "ansicyan", + "ansigray", + # High intensity, bright. (One or two components 0xff, the other 0x00. Not supported everywhere.) + "ansibrightblack", + "ansibrightred", + "ansibrightgreen", + "ansibrightyellow", + "ansibrightblue", + "ansibrightmagenta", + "ansibrightcyan", + "ansiwhite", +] + + +# People don't use the same ANSI color names everywhere. In prompt_toolkit 1.0 +# we used some unconventional names (which were contributed like that to +# Pygments). This is fixed now, but we still support the old names. + +# The table below maps the old aliases to the current names. +ANSI_COLOR_NAMES_ALIASES: dict[str, str] = { + "ansidarkgray": "ansibrightblack", + "ansiteal": "ansicyan", + "ansiturquoise": "ansibrightcyan", + "ansibrown": "ansiyellow", + "ansipurple": "ansimagenta", + "ansifuchsia": "ansibrightmagenta", + "ansilightgray": "ansigray", + "ansidarkred": "ansired", + "ansidarkgreen": "ansigreen", + "ansidarkblue": "ansiblue", +} +assert set(ANSI_COLOR_NAMES_ALIASES.values()).issubset(set(ANSI_COLOR_NAMES)) +assert not (set(ANSI_COLOR_NAMES_ALIASES.keys()) & set(ANSI_COLOR_NAMES)) + + +class BaseStyle(metaclass=ABCMeta): + """ + Abstract base class for prompt_toolkit styles. + """ + + @abstractmethod + def get_attrs_for_style_str( + self, style_str: str, default: Attrs = DEFAULT_ATTRS + ) -> Attrs: + """ + Return :class:`.Attrs` for the given style string. + + :param style_str: The style string. This can contain inline styling as + well as classnames (e.g. "class:title"). + :param default: `Attrs` to be used if no styling was defined. + """ + + @abstractproperty + def style_rules(self) -> list[tuple[str, str]]: + """ + The list of style rules, used to create this style. + (Required for `DynamicStyle` and `_MergedStyle` to work.) + """ + return [] + + @abstractmethod + def invalidation_hash(self) -> Hashable: + """ + Invalidation hash for the style. When this changes over time, the + renderer knows that something in the style changed, and that everything + has to be redrawn. + """ + + +class DummyStyle(BaseStyle): + """ + A style that doesn't style anything. + """ + + def get_attrs_for_style_str( + self, style_str: str, default: Attrs = DEFAULT_ATTRS + ) -> Attrs: + return default + + def invalidation_hash(self) -> Hashable: + return 1 # Always the same value. + + @property + def style_rules(self) -> list[tuple[str, str]]: + return [] + + +class DynamicStyle(BaseStyle): + """ + Style class that can dynamically returns an other Style. + + :param get_style: Callable that returns a :class:`.Style` instance. + """ + + def __init__(self, get_style: Callable[[], BaseStyle | None]): + self.get_style = get_style + self._dummy = DummyStyle() + + def get_attrs_for_style_str( + self, style_str: str, default: Attrs = DEFAULT_ATTRS + ) -> Attrs: + style = self.get_style() or self._dummy + + return style.get_attrs_for_style_str(style_str, default) + + def invalidation_hash(self) -> Hashable: + return (self.get_style() or self._dummy).invalidation_hash() + + @property + def style_rules(self) -> list[tuple[str, str]]: + return (self.get_style() or self._dummy).style_rules diff --git a/src/prompt_toolkit/styles/defaults.py b/src/prompt_toolkit/styles/defaults.py new file mode 100644 index 0000000..75b8dd2 --- /dev/null +++ b/src/prompt_toolkit/styles/defaults.py @@ -0,0 +1,235 @@ +""" +The default styling. +""" +from __future__ import annotations + +from prompt_toolkit.cache import memoized + +from .base import ANSI_COLOR_NAMES, BaseStyle +from .named_colors import NAMED_COLORS +from .style import Style, merge_styles + +__all__ = [ + "default_ui_style", + "default_pygments_style", +] + +#: Default styling. Mapping from classnames to their style definition. +PROMPT_TOOLKIT_STYLE = [ + # Highlighting of search matches in document. + ("search", "bg:ansibrightyellow ansiblack"), + ("search.current", ""), + # Incremental search. + ("incsearch", ""), + ("incsearch.current", "reverse"), + # Highlighting of select text in document. + ("selected", "reverse"), + ("cursor-column", "bg:#dddddd"), + ("cursor-line", "underline"), + ("color-column", "bg:#ccaacc"), + # Highlighting of matching brackets. + ("matching-bracket", ""), + ("matching-bracket.other", "#000000 bg:#aacccc"), + ("matching-bracket.cursor", "#ff8888 bg:#880000"), + # Styling of other cursors, in case of block editing. + ("multiple-cursors", "#000000 bg:#ccccaa"), + # Line numbers. + ("line-number", "#888888"), + ("line-number.current", "bold"), + ("tilde", "#8888ff"), + # Default prompt. + ("prompt", ""), + ("prompt.arg", "noinherit"), + ("prompt.arg.text", ""), + ("prompt.search", "noinherit"), + ("prompt.search.text", ""), + # Search toolbar. + ("search-toolbar", "bold"), + ("search-toolbar.text", "nobold"), + # System toolbar + ("system-toolbar", "bold"), + ("system-toolbar.text", "nobold"), + # "arg" toolbar. + ("arg-toolbar", "bold"), + ("arg-toolbar.text", "nobold"), + # Validation toolbar. + ("validation-toolbar", "bg:#550000 #ffffff"), + ("window-too-small", "bg:#550000 #ffffff"), + # Completions toolbar. + ("completion-toolbar", "bg:#bbbbbb #000000"), + ("completion-toolbar.arrow", "bg:#bbbbbb #000000 bold"), + ("completion-toolbar.completion", "bg:#bbbbbb #000000"), + ("completion-toolbar.completion.current", "bg:#444444 #ffffff"), + # Completions menu. + ("completion-menu", "bg:#bbbbbb #000000"), + ("completion-menu.completion", ""), + # (Note: for the current completion, we use 'reverse' on top of fg/bg + # colors. This is to have proper rendering with NO_COLOR=1). + ("completion-menu.completion.current", "fg:#888888 bg:#ffffff reverse"), + ("completion-menu.meta.completion", "bg:#999999 #000000"), + ("completion-menu.meta.completion.current", "bg:#aaaaaa #000000"), + ("completion-menu.multi-column-meta", "bg:#aaaaaa #000000"), + # Fuzzy matches in completion menu (for FuzzyCompleter). + ("completion-menu.completion fuzzymatch.outside", "fg:#444444"), + ("completion-menu.completion fuzzymatch.inside", "bold"), + ("completion-menu.completion fuzzymatch.inside.character", "underline"), + ("completion-menu.completion.current fuzzymatch.outside", "fg:default"), + ("completion-menu.completion.current fuzzymatch.inside", "nobold"), + # Styling of readline-like completions. + ("readline-like-completions", ""), + ("readline-like-completions.completion", ""), + ("readline-like-completions.completion fuzzymatch.outside", "#888888"), + ("readline-like-completions.completion fuzzymatch.inside", ""), + ("readline-like-completions.completion fuzzymatch.inside.character", "underline"), + # Scrollbars. + ("scrollbar.background", "bg:#aaaaaa"), + ("scrollbar.button", "bg:#444444"), + ("scrollbar.arrow", "noinherit bold"), + # Start/end of scrollbars. Adding 'underline' here provides a nice little + # detail to the progress bar, but it doesn't look good on all terminals. + # ('scrollbar.start', 'underline #ffffff'), + # ('scrollbar.end', 'underline #000000'), + # Auto suggestion text. + ("auto-suggestion", "#666666"), + # Trailing whitespace and tabs. + ("trailing-whitespace", "#999999"), + ("tab", "#999999"), + # When Control-C/D has been pressed. Grayed. + ("aborting", "#888888 bg:default noreverse noitalic nounderline noblink"), + ("exiting", "#888888 bg:default noreverse noitalic nounderline noblink"), + # Entering a Vi digraph. + ("digraph", "#4444ff"), + # Control characters, like ^C, ^X. + ("control-character", "ansiblue"), + # Non-breaking space. + ("nbsp", "underline ansiyellow"), + # Default styling of HTML elements. + ("i", "italic"), + ("u", "underline"), + ("s", "strike"), + ("b", "bold"), + ("em", "italic"), + ("strong", "bold"), + ("del", "strike"), + ("hidden", "hidden"), + # It should be possible to use the style names in HTML. + # <reverse>...</reverse> or <noreverse>...</noreverse>. + ("italic", "italic"), + ("underline", "underline"), + ("strike", "strike"), + ("bold", "bold"), + ("reverse", "reverse"), + ("noitalic", "noitalic"), + ("nounderline", "nounderline"), + ("nostrike", "nostrike"), + ("nobold", "nobold"), + ("noreverse", "noreverse"), + # Prompt bottom toolbar + ("bottom-toolbar", "reverse"), +] + + +# Style that will turn for instance the class 'red' into 'red'. +COLORS_STYLE = [(name, "fg:" + name) for name in ANSI_COLOR_NAMES] + [ + (name.lower(), "fg:" + name) for name in NAMED_COLORS +] + + +WIDGETS_STYLE = [ + # Dialog windows. + ("dialog", "bg:#4444ff"), + ("dialog.body", "bg:#ffffff #000000"), + ("dialog.body text-area", "bg:#cccccc"), + ("dialog.body text-area last-line", "underline"), + ("dialog frame.label", "#ff0000 bold"), + # Scrollbars in dialogs. + ("dialog.body scrollbar.background", ""), + ("dialog.body scrollbar.button", "bg:#000000"), + ("dialog.body scrollbar.arrow", ""), + ("dialog.body scrollbar.start", "nounderline"), + ("dialog.body scrollbar.end", "nounderline"), + # Buttons. + ("button", ""), + ("button.arrow", "bold"), + ("button.focused", "bg:#aa0000 #ffffff"), + # Menu bars. + ("menu-bar", "bg:#aaaaaa #000000"), + ("menu-bar.selected-item", "bg:#ffffff #000000"), + ("menu", "bg:#888888 #ffffff"), + ("menu.border", "#aaaaaa"), + ("menu.border shadow", "#444444"), + # Shadows. + ("dialog shadow", "bg:#000088"), + ("dialog.body shadow", "bg:#aaaaaa"), + ("progress-bar", "bg:#000088"), + ("progress-bar.used", "bg:#ff0000"), +] + + +# The default Pygments style, include this by default in case a Pygments lexer +# is used. +PYGMENTS_DEFAULT_STYLE = { + "pygments.whitespace": "#bbbbbb", + "pygments.comment": "italic #408080", + "pygments.comment.preproc": "noitalic #bc7a00", + "pygments.keyword": "bold #008000", + "pygments.keyword.pseudo": "nobold", + "pygments.keyword.type": "nobold #b00040", + "pygments.operator": "#666666", + "pygments.operator.word": "bold #aa22ff", + "pygments.name.builtin": "#008000", + "pygments.name.function": "#0000ff", + "pygments.name.class": "bold #0000ff", + "pygments.name.namespace": "bold #0000ff", + "pygments.name.exception": "bold #d2413a", + "pygments.name.variable": "#19177c", + "pygments.name.constant": "#880000", + "pygments.name.label": "#a0a000", + "pygments.name.entity": "bold #999999", + "pygments.name.attribute": "#7d9029", + "pygments.name.tag": "bold #008000", + "pygments.name.decorator": "#aa22ff", + # Note: In Pygments, Token.String is an alias for Token.Literal.String, + # and Token.Number as an alias for Token.Literal.Number. + "pygments.literal.string": "#ba2121", + "pygments.literal.string.doc": "italic", + "pygments.literal.string.interpol": "bold #bb6688", + "pygments.literal.string.escape": "bold #bb6622", + "pygments.literal.string.regex": "#bb6688", + "pygments.literal.string.symbol": "#19177c", + "pygments.literal.string.other": "#008000", + "pygments.literal.number": "#666666", + "pygments.generic.heading": "bold #000080", + "pygments.generic.subheading": "bold #800080", + "pygments.generic.deleted": "#a00000", + "pygments.generic.inserted": "#00a000", + "pygments.generic.error": "#ff0000", + "pygments.generic.emph": "italic", + "pygments.generic.strong": "bold", + "pygments.generic.prompt": "bold #000080", + "pygments.generic.output": "#888", + "pygments.generic.traceback": "#04d", + "pygments.error": "border:#ff0000", +} + + +@memoized() +def default_ui_style() -> BaseStyle: + """ + Create a default `Style` object. + """ + return merge_styles( + [ + Style(PROMPT_TOOLKIT_STYLE), + Style(COLORS_STYLE), + Style(WIDGETS_STYLE), + ] + ) + + +@memoized() +def default_pygments_style() -> Style: + """ + Create a `Style` object that contains the default Pygments style. + """ + return Style.from_dict(PYGMENTS_DEFAULT_STYLE) diff --git a/src/prompt_toolkit/styles/named_colors.py b/src/prompt_toolkit/styles/named_colors.py new file mode 100644 index 0000000..0395c8b --- /dev/null +++ b/src/prompt_toolkit/styles/named_colors.py @@ -0,0 +1,161 @@ +""" +All modern web browsers support these 140 color names. +Taken from: https://www.w3schools.com/colors/colors_names.asp +""" +from __future__ import annotations + +__all__ = [ + "NAMED_COLORS", +] + + +NAMED_COLORS: dict[str, str] = { + "AliceBlue": "#f0f8ff", + "AntiqueWhite": "#faebd7", + "Aqua": "#00ffff", + "Aquamarine": "#7fffd4", + "Azure": "#f0ffff", + "Beige": "#f5f5dc", + "Bisque": "#ffe4c4", + "Black": "#000000", + "BlanchedAlmond": "#ffebcd", + "Blue": "#0000ff", + "BlueViolet": "#8a2be2", + "Brown": "#a52a2a", + "BurlyWood": "#deb887", + "CadetBlue": "#5f9ea0", + "Chartreuse": "#7fff00", + "Chocolate": "#d2691e", + "Coral": "#ff7f50", + "CornflowerBlue": "#6495ed", + "Cornsilk": "#fff8dc", + "Crimson": "#dc143c", + "Cyan": "#00ffff", + "DarkBlue": "#00008b", + "DarkCyan": "#008b8b", + "DarkGoldenRod": "#b8860b", + "DarkGray": "#a9a9a9", + "DarkGreen": "#006400", + "DarkGrey": "#a9a9a9", + "DarkKhaki": "#bdb76b", + "DarkMagenta": "#8b008b", + "DarkOliveGreen": "#556b2f", + "DarkOrange": "#ff8c00", + "DarkOrchid": "#9932cc", + "DarkRed": "#8b0000", + "DarkSalmon": "#e9967a", + "DarkSeaGreen": "#8fbc8f", + "DarkSlateBlue": "#483d8b", + "DarkSlateGray": "#2f4f4f", + "DarkSlateGrey": "#2f4f4f", + "DarkTurquoise": "#00ced1", + "DarkViolet": "#9400d3", + "DeepPink": "#ff1493", + "DeepSkyBlue": "#00bfff", + "DimGray": "#696969", + "DimGrey": "#696969", + "DodgerBlue": "#1e90ff", + "FireBrick": "#b22222", + "FloralWhite": "#fffaf0", + "ForestGreen": "#228b22", + "Fuchsia": "#ff00ff", + "Gainsboro": "#dcdcdc", + "GhostWhite": "#f8f8ff", + "Gold": "#ffd700", + "GoldenRod": "#daa520", + "Gray": "#808080", + "Green": "#008000", + "GreenYellow": "#adff2f", + "Grey": "#808080", + "HoneyDew": "#f0fff0", + "HotPink": "#ff69b4", + "IndianRed": "#cd5c5c", + "Indigo": "#4b0082", + "Ivory": "#fffff0", + "Khaki": "#f0e68c", + "Lavender": "#e6e6fa", + "LavenderBlush": "#fff0f5", + "LawnGreen": "#7cfc00", + "LemonChiffon": "#fffacd", + "LightBlue": "#add8e6", + "LightCoral": "#f08080", + "LightCyan": "#e0ffff", + "LightGoldenRodYellow": "#fafad2", + "LightGray": "#d3d3d3", + "LightGreen": "#90ee90", + "LightGrey": "#d3d3d3", + "LightPink": "#ffb6c1", + "LightSalmon": "#ffa07a", + "LightSeaGreen": "#20b2aa", + "LightSkyBlue": "#87cefa", + "LightSlateGray": "#778899", + "LightSlateGrey": "#778899", + "LightSteelBlue": "#b0c4de", + "LightYellow": "#ffffe0", + "Lime": "#00ff00", + "LimeGreen": "#32cd32", + "Linen": "#faf0e6", + "Magenta": "#ff00ff", + "Maroon": "#800000", + "MediumAquaMarine": "#66cdaa", + "MediumBlue": "#0000cd", + "MediumOrchid": "#ba55d3", + "MediumPurple": "#9370db", + "MediumSeaGreen": "#3cb371", + "MediumSlateBlue": "#7b68ee", + "MediumSpringGreen": "#00fa9a", + "MediumTurquoise": "#48d1cc", + "MediumVioletRed": "#c71585", + "MidnightBlue": "#191970", + "MintCream": "#f5fffa", + "MistyRose": "#ffe4e1", + "Moccasin": "#ffe4b5", + "NavajoWhite": "#ffdead", + "Navy": "#000080", + "OldLace": "#fdf5e6", + "Olive": "#808000", + "OliveDrab": "#6b8e23", + "Orange": "#ffa500", + "OrangeRed": "#ff4500", + "Orchid": "#da70d6", + "PaleGoldenRod": "#eee8aa", + "PaleGreen": "#98fb98", + "PaleTurquoise": "#afeeee", + "PaleVioletRed": "#db7093", + "PapayaWhip": "#ffefd5", + "PeachPuff": "#ffdab9", + "Peru": "#cd853f", + "Pink": "#ffc0cb", + "Plum": "#dda0dd", + "PowderBlue": "#b0e0e6", + "Purple": "#800080", + "RebeccaPurple": "#663399", + "Red": "#ff0000", + "RosyBrown": "#bc8f8f", + "RoyalBlue": "#4169e1", + "SaddleBrown": "#8b4513", + "Salmon": "#fa8072", + "SandyBrown": "#f4a460", + "SeaGreen": "#2e8b57", + "SeaShell": "#fff5ee", + "Sienna": "#a0522d", + "Silver": "#c0c0c0", + "SkyBlue": "#87ceeb", + "SlateBlue": "#6a5acd", + "SlateGray": "#708090", + "SlateGrey": "#708090", + "Snow": "#fffafa", + "SpringGreen": "#00ff7f", + "SteelBlue": "#4682b4", + "Tan": "#d2b48c", + "Teal": "#008080", + "Thistle": "#d8bfd8", + "Tomato": "#ff6347", + "Turquoise": "#40e0d0", + "Violet": "#ee82ee", + "Wheat": "#f5deb3", + "White": "#ffffff", + "WhiteSmoke": "#f5f5f5", + "Yellow": "#ffff00", + "YellowGreen": "#9acd32", +} diff --git a/src/prompt_toolkit/styles/pygments.py b/src/prompt_toolkit/styles/pygments.py new file mode 100644 index 0000000..3e101f1 --- /dev/null +++ b/src/prompt_toolkit/styles/pygments.py @@ -0,0 +1,69 @@ +""" +Adaptor for building prompt_toolkit styles, starting from a Pygments style. + +Usage:: + + from pygments.styles.tango import TangoStyle + style = style_from_pygments_cls(pygments_style_cls=TangoStyle) +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .style import Style + +if TYPE_CHECKING: + from pygments.style import Style as PygmentsStyle + from pygments.token import Token + + +__all__ = [ + "style_from_pygments_cls", + "style_from_pygments_dict", + "pygments_token_to_classname", +] + + +def style_from_pygments_cls(pygments_style_cls: type[PygmentsStyle]) -> Style: + """ + Shortcut to create a :class:`.Style` instance from a Pygments style class + and a style dictionary. + + Example:: + + from prompt_toolkit.styles.from_pygments import style_from_pygments_cls + from pygments.styles import get_style_by_name + style = style_from_pygments_cls(get_style_by_name('monokai')) + + :param pygments_style_cls: Pygments style class to start from. + """ + # Import inline. + from pygments.style import Style as PygmentsStyle + + assert issubclass(pygments_style_cls, PygmentsStyle) + + return style_from_pygments_dict(pygments_style_cls.styles) + + +def style_from_pygments_dict(pygments_dict: dict[Token, str]) -> Style: + """ + Create a :class:`.Style` instance from a Pygments style dictionary. + (One that maps Token objects to style strings.) + """ + pygments_style = [] + + for token, style in pygments_dict.items(): + pygments_style.append((pygments_token_to_classname(token), style)) + + return Style(pygments_style) + + +def pygments_token_to_classname(token: Token) -> str: + """ + Turn e.g. `Token.Name.Exception` into `'pygments.name.exception'`. + + (Our Pygments lexer will also turn the tokens that pygments produces in a + prompt_toolkit list of fragments that match these styling rules.) + """ + parts = ("pygments",) + token + return ".".join(parts).lower() diff --git a/src/prompt_toolkit/styles/style.py b/src/prompt_toolkit/styles/style.py new file mode 100644 index 0000000..1abee0f --- /dev/null +++ b/src/prompt_toolkit/styles/style.py @@ -0,0 +1,400 @@ +""" +Tool for creating styles from a dictionary. +""" +from __future__ import annotations + +import itertools +import re +from enum import Enum +from typing import Hashable, TypeVar + +from prompt_toolkit.cache import SimpleCache + +from .base import ( + ANSI_COLOR_NAMES, + ANSI_COLOR_NAMES_ALIASES, + DEFAULT_ATTRS, + Attrs, + BaseStyle, +) +from .named_colors import NAMED_COLORS + +__all__ = [ + "Style", + "parse_color", + "Priority", + "merge_styles", +] + +_named_colors_lowercase = {k.lower(): v.lstrip("#") for k, v in NAMED_COLORS.items()} + + +def parse_color(text: str) -> str: + """ + Parse/validate color format. + + Like in Pygments, but also support the ANSI color names. + (These will map to the colors of the 16 color palette.) + """ + # ANSI color names. + if text in ANSI_COLOR_NAMES: + return text + if text in ANSI_COLOR_NAMES_ALIASES: + return ANSI_COLOR_NAMES_ALIASES[text] + + # 140 named colors. + try: + # Replace by 'hex' value. + return _named_colors_lowercase[text.lower()] + except KeyError: + pass + + # Hex codes. + if text[0:1] == "#": + col = text[1:] + + # Keep this for backwards-compatibility (Pygments does it). + # I don't like the '#' prefix for named colors. + if col in ANSI_COLOR_NAMES: + return col + elif col in ANSI_COLOR_NAMES_ALIASES: + return ANSI_COLOR_NAMES_ALIASES[col] + + # 6 digit hex color. + elif len(col) == 6: + return col + + # 3 digit hex color. + elif len(col) == 3: + return col[0] * 2 + col[1] * 2 + col[2] * 2 + + # Default. + elif text in ("", "default"): + return text + + raise ValueError("Wrong color format %r" % text) + + +# Attributes, when they are not filled in by a style. None means that we take +# the value from the parent. +_EMPTY_ATTRS = Attrs( + color=None, + bgcolor=None, + bold=None, + underline=None, + strike=None, + italic=None, + blink=None, + reverse=None, + hidden=None, +) + + +def _expand_classname(classname: str) -> list[str]: + """ + Split a single class name at the `.` operator, and build a list of classes. + + E.g. 'a.b.c' becomes ['a', 'a.b', 'a.b.c'] + """ + result = [] + parts = classname.split(".") + + for i in range(1, len(parts) + 1): + result.append(".".join(parts[:i]).lower()) + + return result + + +def _parse_style_str(style_str: str) -> Attrs: + """ + Take a style string, e.g. 'bg:red #88ff00 class:title' + and return a `Attrs` instance. + """ + # Start from default Attrs. + if "noinherit" in style_str: + attrs = DEFAULT_ATTRS + else: + attrs = _EMPTY_ATTRS + + # Now update with the given attributes. + for part in style_str.split(): + if part == "noinherit": + pass + elif part == "bold": + attrs = attrs._replace(bold=True) + elif part == "nobold": + attrs = attrs._replace(bold=False) + elif part == "italic": + attrs = attrs._replace(italic=True) + elif part == "noitalic": + attrs = attrs._replace(italic=False) + elif part == "underline": + attrs = attrs._replace(underline=True) + elif part == "nounderline": + attrs = attrs._replace(underline=False) + elif part == "strike": + attrs = attrs._replace(strike=True) + elif part == "nostrike": + attrs = attrs._replace(strike=False) + + # prompt_toolkit extensions. Not in Pygments. + elif part == "blink": + attrs = attrs._replace(blink=True) + elif part == "noblink": + attrs = attrs._replace(blink=False) + elif part == "reverse": + attrs = attrs._replace(reverse=True) + elif part == "noreverse": + attrs = attrs._replace(reverse=False) + elif part == "hidden": + attrs = attrs._replace(hidden=True) + elif part == "nohidden": + attrs = attrs._replace(hidden=False) + + # Pygments properties that we ignore. + elif part in ("roman", "sans", "mono"): + pass + elif part.startswith("border:"): + pass + + # Ignore pieces in between square brackets. This is internal stuff. + # Like '[transparent]' or '[set-cursor-position]'. + elif part.startswith("[") and part.endswith("]"): + pass + + # Colors. + elif part.startswith("bg:"): + attrs = attrs._replace(bgcolor=parse_color(part[3:])) + elif part.startswith("fg:"): # The 'fg:' prefix is optional. + attrs = attrs._replace(color=parse_color(part[3:])) + else: + attrs = attrs._replace(color=parse_color(part)) + + return attrs + + +CLASS_NAMES_RE = re.compile(r"^[a-z0-9.\s_-]*$") # This one can't contain a comma! + + +class Priority(Enum): + """ + The priority of the rules, when a style is created from a dictionary. + + In a `Style`, rules that are defined later will always override previous + defined rules, however in a dictionary, the key order was arbitrary before + Python 3.6. This means that the style could change at random between rules. + + We have two options: + + - `DICT_KEY_ORDER`: This means, iterate through the dictionary, and take + the key/value pairs in order as they come. This is a good option if you + have Python >3.6. Rules at the end will override rules at the beginning. + - `MOST_PRECISE`: keys that are defined with most precision will get higher + priority. (More precise means: more elements.) + """ + + DICT_KEY_ORDER = "KEY_ORDER" + MOST_PRECISE = "MOST_PRECISE" + + +# We don't support Python versions older than 3.6 anymore, so we can always +# depend on dictionary ordering. This is the default. +default_priority = Priority.DICT_KEY_ORDER + + +class Style(BaseStyle): + """ + Create a ``Style`` instance from a list of style rules. + + The `style_rules` is supposed to be a list of ('classnames', 'style') tuples. + The classnames are a whitespace separated string of class names and the + style string is just like a Pygments style definition, but with a few + additions: it supports 'reverse' and 'blink'. + + Later rules always override previous rules. + + Usage:: + + Style([ + ('title', '#ff0000 bold underline'), + ('something-else', 'reverse'), + ('class1 class2', 'reverse'), + ]) + + The ``from_dict`` classmethod is similar, but takes a dictionary as input. + """ + + def __init__(self, style_rules: list[tuple[str, str]]) -> None: + class_names_and_attrs = [] + + # Loop through the rules in the order they were defined. + # Rules that are defined later get priority. + for class_names, style_str in style_rules: + assert CLASS_NAMES_RE.match(class_names), repr(class_names) + + # The order of the class names doesn't matter. + # (But the order of rules does matter.) + class_names_set = frozenset(class_names.lower().split()) + attrs = _parse_style_str(style_str) + + class_names_and_attrs.append((class_names_set, attrs)) + + self._style_rules = style_rules + self.class_names_and_attrs = class_names_and_attrs + + @property + def style_rules(self) -> list[tuple[str, str]]: + return self._style_rules + + @classmethod + def from_dict( + cls, style_dict: dict[str, str], priority: Priority = default_priority + ) -> Style: + """ + :param style_dict: Style dictionary. + :param priority: `Priority` value. + """ + if priority == Priority.MOST_PRECISE: + + def key(item: tuple[str, str]) -> int: + # Split on '.' and whitespace. Count elements. + return sum(len(i.split(".")) for i in item[0].split()) + + return cls(sorted(style_dict.items(), key=key)) + else: + return cls(list(style_dict.items())) + + def get_attrs_for_style_str( + self, style_str: str, default: Attrs = DEFAULT_ATTRS + ) -> Attrs: + """ + Get `Attrs` for the given style string. + """ + list_of_attrs = [default] + class_names: set[str] = set() + + # Apply default styling. + for names, attr in self.class_names_and_attrs: + if not names: + list_of_attrs.append(attr) + + # Go from left to right through the style string. Things on the right + # take precedence. + for part in style_str.split(): + # This part represents a class. + # Do lookup of this class name in the style definition, as well + # as all class combinations that we have so far. + if part.startswith("class:"): + # Expand all class names (comma separated list). + new_class_names = [] + for p in part[6:].lower().split(","): + new_class_names.extend(_expand_classname(p)) + + for new_name in new_class_names: + # Build a set of all possible class combinations to be applied. + combos = set() + combos.add(frozenset([new_name])) + + for count in range(1, len(class_names) + 1): + for c2 in itertools.combinations(class_names, count): + combos.add(frozenset(c2 + (new_name,))) + + # Apply the styles that match these class names. + for names, attr in self.class_names_and_attrs: + if names in combos: + list_of_attrs.append(attr) + + class_names.add(new_name) + + # Process inline style. + else: + inline_attrs = _parse_style_str(part) + list_of_attrs.append(inline_attrs) + + return _merge_attrs(list_of_attrs) + + def invalidation_hash(self) -> Hashable: + return id(self.class_names_and_attrs) + + +_T = TypeVar("_T") + + +def _merge_attrs(list_of_attrs: list[Attrs]) -> Attrs: + """ + Take a list of :class:`.Attrs` instances and merge them into one. + Every `Attr` in the list can override the styling of the previous one. So, + the last one has highest priority. + """ + + def _or(*values: _T) -> _T: + "Take first not-None value, starting at the end." + for v in values[::-1]: + if v is not None: + return v + raise ValueError # Should not happen, there's always one non-null value. + + return Attrs( + color=_or("", *[a.color for a in list_of_attrs]), + bgcolor=_or("", *[a.bgcolor for a in list_of_attrs]), + bold=_or(False, *[a.bold for a in list_of_attrs]), + underline=_or(False, *[a.underline for a in list_of_attrs]), + strike=_or(False, *[a.strike for a in list_of_attrs]), + italic=_or(False, *[a.italic for a in list_of_attrs]), + blink=_or(False, *[a.blink for a in list_of_attrs]), + reverse=_or(False, *[a.reverse for a in list_of_attrs]), + hidden=_or(False, *[a.hidden for a in list_of_attrs]), + ) + + +def merge_styles(styles: list[BaseStyle]) -> _MergedStyle: + """ + Merge multiple `Style` objects. + """ + styles = [s for s in styles if s is not None] + return _MergedStyle(styles) + + +class _MergedStyle(BaseStyle): + """ + Merge multiple `Style` objects into one. + This is supposed to ensure consistency: if any of the given styles changes, + then this style will be updated. + """ + + # NOTE: previously, we used an algorithm where we did not generate the + # combined style. Instead this was a proxy that called one style + # after the other, passing the outcome of the previous style as the + # default for the next one. This did not work, because that way, the + # priorities like described in the `Style` class don't work. + # 'class:aborted' was for instance never displayed in gray, because + # the next style specified a default color for any text. (The + # explicit styling of class:aborted should have taken priority, + # because it was more precise.) + def __init__(self, styles: list[BaseStyle]) -> None: + self.styles = styles + self._style: SimpleCache[Hashable, Style] = SimpleCache(maxsize=1) + + @property + def _merged_style(self) -> Style: + "The `Style` object that has the other styles merged together." + + def get() -> Style: + return Style(self.style_rules) + + return self._style.get(self.invalidation_hash(), get) + + @property + def style_rules(self) -> list[tuple[str, str]]: + style_rules = [] + for s in self.styles: + style_rules.extend(s.style_rules) + return style_rules + + def get_attrs_for_style_str( + self, style_str: str, default: Attrs = DEFAULT_ATTRS + ) -> Attrs: + return self._merged_style.get_attrs_for_style_str(style_str, default) + + def invalidation_hash(self) -> Hashable: + return tuple(s.invalidation_hash() for s in self.styles) diff --git a/src/prompt_toolkit/styles/style_transformation.py b/src/prompt_toolkit/styles/style_transformation.py new file mode 100644 index 0000000..fbb5a63 --- /dev/null +++ b/src/prompt_toolkit/styles/style_transformation.py @@ -0,0 +1,373 @@ +""" +Collection of style transformations. + +Think of it as a kind of color post processing after the rendering is done. +This could be used for instance to change the contrast/saturation; swap light +and dark colors or even change certain colors for other colors. + +When the UI is rendered, these transformations can be applied right after the +style strings are turned into `Attrs` objects that represent the actual +formatting. +""" +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from colorsys import hls_to_rgb, rgb_to_hls +from typing import Callable, Hashable, Sequence + +from prompt_toolkit.cache import memoized +from prompt_toolkit.filters import FilterOrBool, to_filter +from prompt_toolkit.utils import AnyFloat, to_float, to_str + +from .base import ANSI_COLOR_NAMES, Attrs +from .style import parse_color + +__all__ = [ + "StyleTransformation", + "SwapLightAndDarkStyleTransformation", + "ReverseStyleTransformation", + "SetDefaultColorStyleTransformation", + "AdjustBrightnessStyleTransformation", + "DummyStyleTransformation", + "ConditionalStyleTransformation", + "DynamicStyleTransformation", + "merge_style_transformations", +] + + +class StyleTransformation(metaclass=ABCMeta): + """ + Base class for any style transformation. + """ + + @abstractmethod + def transform_attrs(self, attrs: Attrs) -> Attrs: + """ + Take an `Attrs` object and return a new `Attrs` object. + + Remember that the color formats can be either "ansi..." or a 6 digit + lowercase hexadecimal color (without '#' prefix). + """ + + def invalidation_hash(self) -> Hashable: + """ + When this changes, the cache should be invalidated. + """ + return f"{self.__class__.__name__}-{id(self)}" + + +class SwapLightAndDarkStyleTransformation(StyleTransformation): + """ + Turn dark colors into light colors and the other way around. + + This is meant to make color schemes that work on a dark background usable + on a light background (and the other way around). + + Notice that this doesn't swap foreground and background like "reverse" + does. It turns light green into dark green and the other way around. + Foreground and background colors are considered individually. + + Also notice that when <reverse> is used somewhere and no colors are given + in particular (like what is the default for the bottom toolbar), then this + doesn't change anything. This is what makes sense, because when the + 'default' color is chosen, it's what works best for the terminal, and + reverse works good with that. + """ + + def transform_attrs(self, attrs: Attrs) -> Attrs: + """ + Return the `Attrs` used when opposite luminosity should be used. + """ + # Reverse colors. + attrs = attrs._replace(color=get_opposite_color(attrs.color)) + attrs = attrs._replace(bgcolor=get_opposite_color(attrs.bgcolor)) + + return attrs + + +class ReverseStyleTransformation(StyleTransformation): + """ + Swap the 'reverse' attribute. + + (This is still experimental.) + """ + + def transform_attrs(self, attrs: Attrs) -> Attrs: + return attrs._replace(reverse=not attrs.reverse) + + +class SetDefaultColorStyleTransformation(StyleTransformation): + """ + Set default foreground/background color for output that doesn't specify + anything. This is useful for overriding the terminal default colors. + + :param fg: Color string or callable that returns a color string for the + foreground. + :param bg: Like `fg`, but for the background. + """ + + def __init__( + self, fg: str | Callable[[], str], bg: str | Callable[[], str] + ) -> None: + self.fg = fg + self.bg = bg + + def transform_attrs(self, attrs: Attrs) -> Attrs: + if attrs.bgcolor in ("", "default"): + attrs = attrs._replace(bgcolor=parse_color(to_str(self.bg))) + + if attrs.color in ("", "default"): + attrs = attrs._replace(color=parse_color(to_str(self.fg))) + + return attrs + + def invalidation_hash(self) -> Hashable: + return ( + "set-default-color", + to_str(self.fg), + to_str(self.bg), + ) + + +class AdjustBrightnessStyleTransformation(StyleTransformation): + """ + Adjust the brightness to improve the rendering on either dark or light + backgrounds. + + For dark backgrounds, it's best to increase `min_brightness`. For light + backgrounds it's best to decrease `max_brightness`. Usually, only one + setting is adjusted. + + This will only change the brightness for text that has a foreground color + defined, but no background color. It works best for 256 or true color + output. + + .. note:: Notice that there is no universal way to detect whether the + application is running in a light or dark terminal. As a + developer of an command line application, you'll have to make + this configurable for the user. + + :param min_brightness: Float between 0.0 and 1.0 or a callable that returns + a float. + :param max_brightness: Float between 0.0 and 1.0 or a callable that returns + a float. + """ + + def __init__( + self, min_brightness: AnyFloat = 0.0, max_brightness: AnyFloat = 1.0 + ) -> None: + self.min_brightness = min_brightness + self.max_brightness = max_brightness + + def transform_attrs(self, attrs: Attrs) -> Attrs: + min_brightness = to_float(self.min_brightness) + max_brightness = to_float(self.max_brightness) + assert 0 <= min_brightness <= 1 + assert 0 <= max_brightness <= 1 + + # Don't do anything if the whole brightness range is acceptable. + # This also avoids turning ansi colors into RGB sequences. + if min_brightness == 0.0 and max_brightness == 1.0: + return attrs + + # If a foreground color is given without a background color. + no_background = not attrs.bgcolor or attrs.bgcolor == "default" + has_fgcolor = attrs.color and attrs.color != "ansidefault" + + if has_fgcolor and no_background: + # Calculate new RGB values. + r, g, b = self._color_to_rgb(attrs.color or "") + hue, brightness, saturation = rgb_to_hls(r, g, b) + brightness = self._interpolate_brightness( + brightness, min_brightness, max_brightness + ) + r, g, b = hls_to_rgb(hue, brightness, saturation) + new_color = f"{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}" + + attrs = attrs._replace(color=new_color) + + return attrs + + def _color_to_rgb(self, color: str) -> tuple[float, float, float]: + """ + Parse `style.Attrs` color into RGB tuple. + """ + # Do RGB lookup for ANSI colors. + try: + from prompt_toolkit.output.vt100 import ANSI_COLORS_TO_RGB + + r, g, b = ANSI_COLORS_TO_RGB[color] + return r / 255.0, g / 255.0, b / 255.0 + except KeyError: + pass + + # Parse RRGGBB format. + return ( + int(color[0:2], 16) / 255.0, + int(color[2:4], 16) / 255.0, + int(color[4:6], 16) / 255.0, + ) + + # NOTE: we don't have to support named colors here. They are already + # transformed into RGB values in `style.parse_color`. + + def _interpolate_brightness( + self, value: float, min_brightness: float, max_brightness: float + ) -> float: + """ + Map the brightness to the (min_brightness..max_brightness) range. + """ + return min_brightness + (max_brightness - min_brightness) * value + + def invalidation_hash(self) -> Hashable: + return ( + "adjust-brightness", + to_float(self.min_brightness), + to_float(self.max_brightness), + ) + + +class DummyStyleTransformation(StyleTransformation): + """ + Don't transform anything at all. + """ + + def transform_attrs(self, attrs: Attrs) -> Attrs: + return attrs + + def invalidation_hash(self) -> Hashable: + # Always return the same hash for these dummy instances. + return "dummy-style-transformation" + + +class DynamicStyleTransformation(StyleTransformation): + """ + StyleTransformation class that can dynamically returns any + `StyleTransformation`. + + :param get_style_transformation: Callable that returns a + :class:`.StyleTransformation` instance. + """ + + def __init__( + self, get_style_transformation: Callable[[], StyleTransformation | None] + ) -> None: + self.get_style_transformation = get_style_transformation + + def transform_attrs(self, attrs: Attrs) -> Attrs: + style_transformation = ( + self.get_style_transformation() or DummyStyleTransformation() + ) + return style_transformation.transform_attrs(attrs) + + def invalidation_hash(self) -> Hashable: + style_transformation = ( + self.get_style_transformation() or DummyStyleTransformation() + ) + return style_transformation.invalidation_hash() + + +class ConditionalStyleTransformation(StyleTransformation): + """ + Apply the style transformation depending on a condition. + """ + + def __init__( + self, style_transformation: StyleTransformation, filter: FilterOrBool + ) -> None: + self.style_transformation = style_transformation + self.filter = to_filter(filter) + + def transform_attrs(self, attrs: Attrs) -> Attrs: + if self.filter(): + return self.style_transformation.transform_attrs(attrs) + return attrs + + def invalidation_hash(self) -> Hashable: + return (self.filter(), self.style_transformation.invalidation_hash()) + + +class _MergedStyleTransformation(StyleTransformation): + def __init__(self, style_transformations: Sequence[StyleTransformation]) -> None: + self.style_transformations = style_transformations + + def transform_attrs(self, attrs: Attrs) -> Attrs: + for transformation in self.style_transformations: + attrs = transformation.transform_attrs(attrs) + return attrs + + def invalidation_hash(self) -> Hashable: + return tuple(t.invalidation_hash() for t in self.style_transformations) + + +def merge_style_transformations( + style_transformations: Sequence[StyleTransformation], +) -> StyleTransformation: + """ + Merge multiple transformations together. + """ + return _MergedStyleTransformation(style_transformations) + + +# Dictionary that maps ANSI color names to their opposite. This is useful for +# turning color schemes that are optimized for a black background usable for a +# white background. +OPPOSITE_ANSI_COLOR_NAMES = { + "ansidefault": "ansidefault", + "ansiblack": "ansiwhite", + "ansired": "ansibrightred", + "ansigreen": "ansibrightgreen", + "ansiyellow": "ansibrightyellow", + "ansiblue": "ansibrightblue", + "ansimagenta": "ansibrightmagenta", + "ansicyan": "ansibrightcyan", + "ansigray": "ansibrightblack", + "ansiwhite": "ansiblack", + "ansibrightred": "ansired", + "ansibrightgreen": "ansigreen", + "ansibrightyellow": "ansiyellow", + "ansibrightblue": "ansiblue", + "ansibrightmagenta": "ansimagenta", + "ansibrightcyan": "ansicyan", + "ansibrightblack": "ansigray", +} +assert set(OPPOSITE_ANSI_COLOR_NAMES.keys()) == set(ANSI_COLOR_NAMES) +assert set(OPPOSITE_ANSI_COLOR_NAMES.values()) == set(ANSI_COLOR_NAMES) + + +@memoized() +def get_opposite_color(colorname: str | None) -> str | None: + """ + Take a color name in either 'ansi...' format or 6 digit RGB, return the + color of opposite luminosity (same hue/saturation). + + This is used for turning color schemes that work on a light background + usable on a dark background. + """ + if colorname is None: # Because color/bgcolor can be None in `Attrs`. + return None + + # Special values. + if colorname in ("", "default"): + return colorname + + # Try ANSI color names. + try: + return OPPOSITE_ANSI_COLOR_NAMES[colorname] + except KeyError: + # Try 6 digit RGB colors. + r = int(colorname[:2], 16) / 255.0 + g = int(colorname[2:4], 16) / 255.0 + b = int(colorname[4:6], 16) / 255.0 + + h, l, s = rgb_to_hls(r, g, b) + + l = 1 - l + + r, g, b = hls_to_rgb(h, l, s) + + r = int(r * 255) + g = int(g * 255) + b = int(b * 255) + + return f"{r:02x}{g:02x}{b:02x}" diff --git a/src/prompt_toolkit/token.py b/src/prompt_toolkit/token.py new file mode 100644 index 0000000..a2c80e5 --- /dev/null +++ b/src/prompt_toolkit/token.py @@ -0,0 +1,10 @@ +""" +""" + +from __future__ import annotations + +__all__ = [ + "ZeroWidthEscape", +] + +ZeroWidthEscape = "[ZeroWidthEscape]" diff --git a/src/prompt_toolkit/utils.py b/src/prompt_toolkit/utils.py new file mode 100644 index 0000000..1a99a28 --- /dev/null +++ b/src/prompt_toolkit/utils.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +import os +import signal +import sys +import threading +from collections import deque +from typing import ( + Callable, + ContextManager, + Dict, + Generator, + Generic, + TypeVar, + Union, +) + +from wcwidth import wcwidth + +__all__ = [ + "Event", + "DummyContext", + "get_cwidth", + "suspend_to_background_supported", + "is_conemu_ansi", + "is_windows", + "in_main_thread", + "get_bell_environment_variable", + "get_term_environment_variable", + "take_using_weights", + "to_str", + "to_int", + "AnyFloat", + "to_float", + "is_dumb_terminal", +] + +# Used to ensure sphinx autodoc does not try to import platform-specific +# stuff when documenting win32.py modules. +SPHINX_AUTODOC_RUNNING = "sphinx.ext.autodoc" in sys.modules + +_Sender = TypeVar("_Sender", covariant=True) + + +class Event(Generic[_Sender]): + """ + Simple event to which event handlers can be attached. For instance:: + + class Cls: + def __init__(self): + # Define event. The first parameter is the sender. + self.event = Event(self) + + obj = Cls() + + def handler(sender): + pass + + # Add event handler by using the += operator. + obj.event += handler + + # Fire event. + obj.event() + """ + + def __init__( + self, sender: _Sender, handler: Callable[[_Sender], None] | None = None + ) -> None: + self.sender = sender + self._handlers: list[Callable[[_Sender], None]] = [] + + if handler is not None: + self += handler + + def __call__(self) -> None: + "Fire event." + for handler in self._handlers: + handler(self.sender) + + def fire(self) -> None: + "Alias for just calling the event." + self() + + def add_handler(self, handler: Callable[[_Sender], None]) -> None: + """ + Add another handler to this callback. + (Handler should be a callable that takes exactly one parameter: the + sender object.) + """ + # Add to list of event handlers. + self._handlers.append(handler) + + def remove_handler(self, handler: Callable[[_Sender], None]) -> None: + """ + Remove a handler from this callback. + """ + if handler in self._handlers: + self._handlers.remove(handler) + + def __iadd__(self, handler: Callable[[_Sender], None]) -> Event[_Sender]: + """ + `event += handler` notation for adding a handler. + """ + self.add_handler(handler) + return self + + def __isub__(self, handler: Callable[[_Sender], None]) -> Event[_Sender]: + """ + `event -= handler` notation for removing a handler. + """ + self.remove_handler(handler) + return self + + +class DummyContext(ContextManager[None]): + """ + (contextlib.nested is not available on Py3) + """ + + def __enter__(self) -> None: + pass + + def __exit__(self, *a: object) -> None: + pass + + +class _CharSizesCache(Dict[str, int]): + """ + Cache for wcwidth sizes. + """ + + LONG_STRING_MIN_LEN = 64 # Minimum string length for considering it long. + MAX_LONG_STRINGS = 16 # Maximum number of long strings to remember. + + def __init__(self) -> None: + super().__init__() + # Keep track of the "long" strings in this cache. + self._long_strings: deque[str] = deque() + + def __missing__(self, string: str) -> int: + # Note: We use the `max(0, ...` because some non printable control + # characters, like e.g. Ctrl-underscore get a -1 wcwidth value. + # It can be possible that these characters end up in the input + # text. + result: int + if len(string) == 1: + result = max(0, wcwidth(string)) + else: + result = sum(self[c] for c in string) + + # Store in cache. + self[string] = result + + # Rotate long strings. + # (It's hard to tell what we can consider short...) + if len(string) > self.LONG_STRING_MIN_LEN: + long_strings = self._long_strings + long_strings.append(string) + + if len(long_strings) > self.MAX_LONG_STRINGS: + key_to_remove = long_strings.popleft() + if key_to_remove in self: + del self[key_to_remove] + + return result + + +_CHAR_SIZES_CACHE = _CharSizesCache() + + +def get_cwidth(string: str) -> int: + """ + Return width of a string. Wrapper around ``wcwidth``. + """ + return _CHAR_SIZES_CACHE[string] + + +def suspend_to_background_supported() -> bool: + """ + Returns `True` when the Python implementation supports + suspend-to-background. This is typically `False' on Windows systems. + """ + return hasattr(signal, "SIGTSTP") + + +def is_windows() -> bool: + """ + True when we are using Windows. + """ + return sys.platform == "win32" # Not 'darwin' or 'linux2' + + +def is_windows_vt100_supported() -> bool: + """ + True when we are using Windows, but VT100 escape sequences are supported. + """ + if sys.platform == "win32": + # Import needs to be inline. Windows libraries are not always available. + from prompt_toolkit.output.windows10 import is_win_vt100_enabled + + return is_win_vt100_enabled() + + return False + + +def is_conemu_ansi() -> bool: + """ + True when the ConEmu Windows console is used. + """ + return sys.platform == "win32" and os.environ.get("ConEmuANSI", "OFF") == "ON" + + +def in_main_thread() -> bool: + """ + True when the current thread is the main thread. + """ + return threading.current_thread().__class__.__name__ == "_MainThread" + + +def get_bell_environment_variable() -> bool: + """ + True if env variable is set to true (true, TRUE, True, 1). + """ + value = os.environ.get("PROMPT_TOOLKIT_BELL", "true") + return value.lower() in ("1", "true") + + +def get_term_environment_variable() -> str: + "Return the $TERM environment variable." + return os.environ.get("TERM", "") + + +_T = TypeVar("_T") + + +def take_using_weights( + items: list[_T], weights: list[int] +) -> Generator[_T, None, None]: + """ + Generator that keeps yielding items from the items list, in proportion to + their weight. For instance:: + + # Getting the first 70 items from this generator should have yielded 10 + # times A, 20 times B and 40 times C, all distributed equally.. + take_using_weights(['A', 'B', 'C'], [5, 10, 20]) + + :param items: List of items to take from. + :param weights: Integers representing the weight. (Numbers have to be + integers, not floats.) + """ + assert len(items) == len(weights) + assert len(items) > 0 + + # Remove items with zero-weight. + items2 = [] + weights2 = [] + for item, w in zip(items, weights): + if w > 0: + items2.append(item) + weights2.append(w) + + items = items2 + weights = weights2 + + # Make sure that we have some items left. + if not items: + raise ValueError("Did't got any items with a positive weight.") + + # + already_taken = [0 for i in items] + item_count = len(items) + max_weight = max(weights) + + i = 0 + while True: + # Each iteration of this loop, we fill up until by (total_weight/max_weight). + adding = True + while adding: + adding = False + + for item_i, item, weight in zip(range(item_count), items, weights): + if already_taken[item_i] < i * weight / float(max_weight): + yield item + already_taken[item_i] += 1 + adding = True + + i += 1 + + +def to_str(value: Callable[[], str] | str) -> str: + "Turn callable or string into string." + if callable(value): + return to_str(value()) + else: + return str(value) + + +def to_int(value: Callable[[], int] | int) -> int: + "Turn callable or int into int." + if callable(value): + return to_int(value()) + else: + return int(value) + + +AnyFloat = Union[Callable[[], float], float] + + +def to_float(value: AnyFloat) -> float: + "Turn callable or float into float." + if callable(value): + return to_float(value()) + else: + return float(value) + + +def is_dumb_terminal(term: str | None = None) -> bool: + """ + True if this terminal type is considered "dumb". + + If so, we should fall back to the simplest possible form of line editing, + without cursor positioning and color support. + """ + if term is None: + return is_dumb_terminal(os.environ.get("TERM", "")) + + return term.lower() in ["dumb", "unknown"] diff --git a/src/prompt_toolkit/validation.py b/src/prompt_toolkit/validation.py new file mode 100644 index 0000000..127445e --- /dev/null +++ b/src/prompt_toolkit/validation.py @@ -0,0 +1,195 @@ +""" +Input validation for a `Buffer`. +(Validators will be called before accepting input.) +""" +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from typing import Callable + +from prompt_toolkit.eventloop import run_in_executor_with_context + +from .document import Document +from .filters import FilterOrBool, to_filter + +__all__ = [ + "ConditionalValidator", + "ValidationError", + "Validator", + "ThreadedValidator", + "DummyValidator", + "DynamicValidator", +] + + +class ValidationError(Exception): + """ + Error raised by :meth:`.Validator.validate`. + + :param cursor_position: The cursor position where the error occurred. + :param message: Text. + """ + + def __init__(self, cursor_position: int = 0, message: str = "") -> None: + super().__init__(message) + self.cursor_position = cursor_position + self.message = message + + def __repr__(self) -> str: + return "{}(cursor_position={!r}, message={!r})".format( + self.__class__.__name__, + self.cursor_position, + self.message, + ) + + +class Validator(metaclass=ABCMeta): + """ + Abstract base class for an input validator. + + A validator is typically created in one of the following two ways: + + - Either by overriding this class and implementing the `validate` method. + - Or by passing a callable to `Validator.from_callable`. + + If the validation takes some time and needs to happen in a background + thread, this can be wrapped in a :class:`.ThreadedValidator`. + """ + + @abstractmethod + def validate(self, document: Document) -> None: + """ + Validate the input. + If invalid, this should raise a :class:`.ValidationError`. + + :param document: :class:`~prompt_toolkit.document.Document` instance. + """ + pass + + async def validate_async(self, document: Document) -> None: + """ + Return a `Future` which is set when the validation is ready. + This function can be overloaded in order to provide an asynchronous + implementation. + """ + try: + self.validate(document) + except ValidationError: + raise + + @classmethod + def from_callable( + cls, + validate_func: Callable[[str], bool], + error_message: str = "Invalid input", + move_cursor_to_end: bool = False, + ) -> Validator: + """ + Create a validator from a simple validate callable. E.g.: + + .. code:: python + + def is_valid(text): + return text in ['hello', 'world'] + Validator.from_callable(is_valid, error_message='Invalid input') + + :param validate_func: Callable that takes the input string, and returns + `True` if the input is valid input. + :param error_message: Message to be displayed if the input is invalid. + :param move_cursor_to_end: Move the cursor to the end of the input, if + the input is invalid. + """ + return _ValidatorFromCallable(validate_func, error_message, move_cursor_to_end) + + +class _ValidatorFromCallable(Validator): + """ + Validate input from a simple callable. + """ + + def __init__( + self, func: Callable[[str], bool], error_message: str, move_cursor_to_end: bool + ) -> None: + self.func = func + self.error_message = error_message + self.move_cursor_to_end = move_cursor_to_end + + def __repr__(self) -> str: + return f"Validator.from_callable({self.func!r})" + + def validate(self, document: Document) -> None: + if not self.func(document.text): + if self.move_cursor_to_end: + index = len(document.text) + else: + index = 0 + + raise ValidationError(cursor_position=index, message=self.error_message) + + +class ThreadedValidator(Validator): + """ + Wrapper that runs input validation in a thread. + (Use this to prevent the user interface from becoming unresponsive if the + input validation takes too much time.) + """ + + def __init__(self, validator: Validator) -> None: + self.validator = validator + + def validate(self, document: Document) -> None: + self.validator.validate(document) + + async def validate_async(self, document: Document) -> None: + """ + Run the `validate` function in a thread. + """ + + def run_validation_thread() -> None: + return self.validate(document) + + await run_in_executor_with_context(run_validation_thread) + + +class DummyValidator(Validator): + """ + Validator class that accepts any input. + """ + + def validate(self, document: Document) -> None: + pass # Don't raise any exception. + + +class ConditionalValidator(Validator): + """ + Validator that can be switched on/off according to + a filter. (This wraps around another validator.) + """ + + def __init__(self, validator: Validator, filter: FilterOrBool) -> None: + self.validator = validator + self.filter = to_filter(filter) + + def validate(self, document: Document) -> None: + # Call the validator only if the filter is active. + if self.filter(): + self.validator.validate(document) + + +class DynamicValidator(Validator): + """ + Validator class that can dynamically returns any Validator. + + :param get_validator: Callable that returns a :class:`.Validator` instance. + """ + + def __init__(self, get_validator: Callable[[], Validator | None]) -> None: + self.get_validator = get_validator + + def validate(self, document: Document) -> None: + validator = self.get_validator() or DummyValidator() + validator.validate(document) + + async def validate_async(self, document: Document) -> None: + validator = self.get_validator() or DummyValidator() + await validator.validate_async(document) diff --git a/src/prompt_toolkit/widgets/__init__.py b/src/prompt_toolkit/widgets/__init__.py new file mode 100644 index 0000000..9d1d4e3 --- /dev/null +++ b/src/prompt_toolkit/widgets/__init__.py @@ -0,0 +1,62 @@ +""" +Collection of reusable components for building full screen applications. +These are higher level abstractions on top of the `prompt_toolkit.layout` +module. + +Most of these widgets implement the ``__pt_container__`` method, which makes it +possible to embed these in the layout like any other container. +""" +from __future__ import annotations + +from .base import ( + Box, + Button, + Checkbox, + CheckboxList, + Frame, + HorizontalLine, + Label, + ProgressBar, + RadioList, + Shadow, + TextArea, + VerticalLine, +) +from .dialogs import Dialog +from .menus import MenuContainer, MenuItem +from .toolbars import ( + ArgToolbar, + CompletionsToolbar, + FormattedTextToolbar, + SearchToolbar, + SystemToolbar, + ValidationToolbar, +) + +__all__ = [ + # Base. + "TextArea", + "Label", + "Button", + "Frame", + "Shadow", + "Box", + "VerticalLine", + "HorizontalLine", + "CheckboxList", + "RadioList", + "Checkbox", + "ProgressBar", + # Toolbars. + "ArgToolbar", + "CompletionsToolbar", + "FormattedTextToolbar", + "SearchToolbar", + "SystemToolbar", + "ValidationToolbar", + # Dialogs. + "Dialog", + # Menus. + "MenuContainer", + "MenuItem", +] diff --git a/src/prompt_toolkit/widgets/base.py b/src/prompt_toolkit/widgets/base.py new file mode 100644 index 0000000..f36a545 --- /dev/null +++ b/src/prompt_toolkit/widgets/base.py @@ -0,0 +1,981 @@ +""" +Collection of reusable components for building full screen applications. + +All of these widgets implement the ``__pt_container__`` method, which makes +them usable in any situation where we are expecting a `prompt_toolkit` +container object. + +.. warning:: + + At this point, the API for these widgets is considered unstable, and can + potentially change between minor releases (we try not too, but no + guarantees are made yet). The public API in + `prompt_toolkit.shortcuts.dialogs` on the other hand is considered stable. +""" +from __future__ import annotations + +from functools import partial +from typing import Callable, Generic, Sequence, TypeVar + +from prompt_toolkit.application.current import get_app +from prompt_toolkit.auto_suggest import AutoSuggest, DynamicAutoSuggest +from prompt_toolkit.buffer import Buffer, BufferAcceptHandler +from prompt_toolkit.completion import Completer, DynamicCompleter +from prompt_toolkit.document import Document +from prompt_toolkit.filters import ( + Condition, + FilterOrBool, + has_focus, + is_done, + is_true, + to_filter, +) +from prompt_toolkit.formatted_text import ( + AnyFormattedText, + StyleAndTextTuples, + Template, + to_formatted_text, +) +from prompt_toolkit.formatted_text.utils import fragment_list_to_text +from prompt_toolkit.history import History +from prompt_toolkit.key_binding.key_bindings import KeyBindings +from prompt_toolkit.key_binding.key_processor import KeyPressEvent +from prompt_toolkit.keys import Keys +from prompt_toolkit.layout.containers import ( + AnyContainer, + ConditionalContainer, + Container, + DynamicContainer, + Float, + FloatContainer, + HSplit, + VSplit, + Window, + WindowAlign, +) +from prompt_toolkit.layout.controls import ( + BufferControl, + FormattedTextControl, + GetLinePrefixCallable, +) +from prompt_toolkit.layout.dimension import AnyDimension, to_dimension +from prompt_toolkit.layout.dimension import Dimension as D +from prompt_toolkit.layout.margins import ( + ConditionalMargin, + NumberedMargin, + ScrollbarMargin, +) +from prompt_toolkit.layout.processors import ( + AppendAutoSuggestion, + BeforeInput, + ConditionalProcessor, + PasswordProcessor, + Processor, +) +from prompt_toolkit.lexers import DynamicLexer, Lexer +from prompt_toolkit.mouse_events import MouseEvent, MouseEventType +from prompt_toolkit.utils import get_cwidth +from prompt_toolkit.validation import DynamicValidator, Validator + +from .toolbars import SearchToolbar + +__all__ = [ + "TextArea", + "Label", + "Button", + "Frame", + "Shadow", + "Box", + "VerticalLine", + "HorizontalLine", + "RadioList", + "CheckboxList", + "Checkbox", # backward compatibility + "ProgressBar", +] + +E = KeyPressEvent + + +class Border: + "Box drawing characters. (Thin)" + + HORIZONTAL = "\u2500" + VERTICAL = "\u2502" + TOP_LEFT = "\u250c" + TOP_RIGHT = "\u2510" + BOTTOM_LEFT = "\u2514" + BOTTOM_RIGHT = "\u2518" + + +class TextArea: + """ + A simple input field. + + This is a higher level abstraction on top of several other classes with + sane defaults. + + This widget does have the most common options, but it does not intend to + cover every single use case. For more configurations options, you can + always build a text area manually, using a + :class:`~prompt_toolkit.buffer.Buffer`, + :class:`~prompt_toolkit.layout.BufferControl` and + :class:`~prompt_toolkit.layout.Window`. + + Buffer attributes: + + :param text: The initial text. + :param multiline: If True, allow multiline input. + :param completer: :class:`~prompt_toolkit.completion.Completer` instance + for auto completion. + :param complete_while_typing: Boolean. + :param accept_handler: Called when `Enter` is pressed (This should be a + callable that takes a buffer as input). + :param history: :class:`~prompt_toolkit.history.History` instance. + :param auto_suggest: :class:`~prompt_toolkit.auto_suggest.AutoSuggest` + instance for input suggestions. + + BufferControl attributes: + + :param password: When `True`, display using asterisks. + :param focusable: When `True`, allow this widget to receive the focus. + :param focus_on_click: When `True`, focus after mouse click. + :param input_processors: `None` or a list of + :class:`~prompt_toolkit.layout.Processor` objects. + :param validator: `None` or a :class:`~prompt_toolkit.validation.Validator` + object. + + Window attributes: + + :param lexer: :class:`~prompt_toolkit.lexers.Lexer` instance for syntax + highlighting. + :param wrap_lines: When `True`, don't scroll horizontally, but wrap lines. + :param width: Window width. (:class:`~prompt_toolkit.layout.Dimension` object.) + :param height: Window height. (:class:`~prompt_toolkit.layout.Dimension` object.) + :param scrollbar: When `True`, display a scroll bar. + :param style: A style string. + :param dont_extend_width: When `True`, don't take up more width then the + preferred width reported by the control. + :param dont_extend_height: When `True`, don't take up more width then the + preferred height reported by the control. + :param get_line_prefix: None or a callable that returns formatted text to + be inserted before a line. It takes a line number (int) and a + wrap_count and returns formatted text. This can be used for + implementation of line continuations, things like Vim "breakindent" and + so on. + + Other attributes: + + :param search_field: An optional `SearchToolbar` object. + """ + + def __init__( + self, + text: str = "", + multiline: FilterOrBool = True, + password: FilterOrBool = False, + lexer: Lexer | None = None, + auto_suggest: AutoSuggest | None = None, + completer: Completer | None = None, + complete_while_typing: FilterOrBool = True, + validator: Validator | None = None, + accept_handler: BufferAcceptHandler | None = None, + history: History | None = None, + focusable: FilterOrBool = True, + focus_on_click: FilterOrBool = False, + wrap_lines: FilterOrBool = True, + read_only: FilterOrBool = False, + width: AnyDimension = None, + height: AnyDimension = None, + dont_extend_height: FilterOrBool = False, + dont_extend_width: FilterOrBool = False, + line_numbers: bool = False, + get_line_prefix: GetLinePrefixCallable | None = None, + scrollbar: bool = False, + style: str = "", + search_field: SearchToolbar | None = None, + preview_search: FilterOrBool = True, + prompt: AnyFormattedText = "", + input_processors: list[Processor] | None = None, + name: str = "", + ) -> None: + if search_field is None: + search_control = None + elif isinstance(search_field, SearchToolbar): + search_control = search_field.control + + if input_processors is None: + input_processors = [] + + # Writeable attributes. + self.completer = completer + self.complete_while_typing = complete_while_typing + self.lexer = lexer + self.auto_suggest = auto_suggest + self.read_only = read_only + self.wrap_lines = wrap_lines + self.validator = validator + + self.buffer = Buffer( + document=Document(text, 0), + multiline=multiline, + read_only=Condition(lambda: is_true(self.read_only)), + completer=DynamicCompleter(lambda: self.completer), + complete_while_typing=Condition( + lambda: is_true(self.complete_while_typing) + ), + validator=DynamicValidator(lambda: self.validator), + auto_suggest=DynamicAutoSuggest(lambda: self.auto_suggest), + accept_handler=accept_handler, + history=history, + name=name, + ) + + self.control = BufferControl( + buffer=self.buffer, + lexer=DynamicLexer(lambda: self.lexer), + input_processors=[ + ConditionalProcessor( + AppendAutoSuggestion(), has_focus(self.buffer) & ~is_done + ), + ConditionalProcessor( + processor=PasswordProcessor(), filter=to_filter(password) + ), + BeforeInput(prompt, style="class:text-area.prompt"), + ] + + input_processors, + search_buffer_control=search_control, + preview_search=preview_search, + focusable=focusable, + focus_on_click=focus_on_click, + ) + + if multiline: + if scrollbar: + right_margins = [ScrollbarMargin(display_arrows=True)] + else: + right_margins = [] + if line_numbers: + left_margins = [NumberedMargin()] + else: + left_margins = [] + else: + height = D.exact(1) + left_margins = [] + right_margins = [] + + style = "class:text-area " + style + + # If no height was given, guarantee height of at least 1. + if height is None: + height = D(min=1) + + self.window = Window( + height=height, + width=width, + dont_extend_height=dont_extend_height, + dont_extend_width=dont_extend_width, + content=self.control, + style=style, + wrap_lines=Condition(lambda: is_true(self.wrap_lines)), + left_margins=left_margins, + right_margins=right_margins, + get_line_prefix=get_line_prefix, + ) + + @property + def text(self) -> str: + """ + The `Buffer` text. + """ + return self.buffer.text + + @text.setter + def text(self, value: str) -> None: + self.document = Document(value, 0) + + @property + def document(self) -> Document: + """ + The `Buffer` document (text + cursor position). + """ + return self.buffer.document + + @document.setter + def document(self, value: Document) -> None: + self.buffer.set_document(value, bypass_readonly=True) + + @property + def accept_handler(self) -> BufferAcceptHandler | None: + """ + The accept handler. Called when the user accepts the input. + """ + return self.buffer.accept_handler + + @accept_handler.setter + def accept_handler(self, value: BufferAcceptHandler) -> None: + self.buffer.accept_handler = value + + def __pt_container__(self) -> Container: + return self.window + + +class Label: + """ + Widget that displays the given text. It is not editable or focusable. + + :param text: Text to display. Can be multiline. All value types accepted by + :class:`prompt_toolkit.layout.FormattedTextControl` are allowed, + including a callable. + :param style: A style string. + :param width: When given, use this width, rather than calculating it from + the text size. + :param dont_extend_width: When `True`, don't take up more width than + preferred, i.e. the length of the longest line of + the text, or value of `width` parameter, if + given. `True` by default + :param dont_extend_height: When `True`, don't take up more width than the + preferred height, i.e. the number of lines of + the text. `False` by default. + """ + + def __init__( + self, + text: AnyFormattedText, + style: str = "", + width: AnyDimension = None, + dont_extend_height: bool = True, + dont_extend_width: bool = False, + align: WindowAlign | Callable[[], WindowAlign] = WindowAlign.LEFT, + # There is no cursor navigation in a label, so it makes sense to always + # wrap lines by default. + wrap_lines: FilterOrBool = True, + ) -> None: + self.text = text + + def get_width() -> AnyDimension: + if width is None: + text_fragments = to_formatted_text(self.text) + text = fragment_list_to_text(text_fragments) + if text: + longest_line = max(get_cwidth(line) for line in text.splitlines()) + else: + return D(preferred=0) + return D(preferred=longest_line) + else: + return width + + self.formatted_text_control = FormattedTextControl(text=lambda: self.text) + + self.window = Window( + content=self.formatted_text_control, + width=get_width, + height=D(min=1), + style="class:label " + style, + dont_extend_height=dont_extend_height, + dont_extend_width=dont_extend_width, + align=align, + wrap_lines=wrap_lines, + ) + + def __pt_container__(self) -> Container: + return self.window + + +class Button: + """ + Clickable button. + + :param text: The caption for the button. + :param handler: `None` or callable. Called when the button is clicked. No + parameters are passed to this callable. Use for instance Python's + `functools.partial` to pass parameters to this callable if needed. + :param width: Width of the button. + """ + + def __init__( + self, + text: str, + handler: Callable[[], None] | None = None, + width: int = 12, + left_symbol: str = "<", + right_symbol: str = ">", + ) -> None: + self.text = text + self.left_symbol = left_symbol + self.right_symbol = right_symbol + self.handler = handler + self.width = width + self.control = FormattedTextControl( + self._get_text_fragments, + key_bindings=self._get_key_bindings(), + focusable=True, + ) + + def get_style() -> str: + if get_app().layout.has_focus(self): + return "class:button.focused" + else: + return "class:button" + + # Note: `dont_extend_width` is False, because we want to allow buttons + # to take more space if the parent container provides more space. + # Otherwise, we will also truncate the text. + # Probably we need a better way here to adjust to width of the + # button to the text. + + self.window = Window( + self.control, + align=WindowAlign.CENTER, + height=1, + width=width, + style=get_style, + dont_extend_width=False, + dont_extend_height=True, + ) + + def _get_text_fragments(self) -> StyleAndTextTuples: + width = self.width - ( + get_cwidth(self.left_symbol) + get_cwidth(self.right_symbol) + ) + text = (f"{{:^{width}}}").format(self.text) + + def handler(mouse_event: MouseEvent) -> None: + if ( + self.handler is not None + and mouse_event.event_type == MouseEventType.MOUSE_UP + ): + self.handler() + + return [ + ("class:button.arrow", self.left_symbol, handler), + ("[SetCursorPosition]", ""), + ("class:button.text", text, handler), + ("class:button.arrow", self.right_symbol, handler), + ] + + def _get_key_bindings(self) -> KeyBindings: + "Key bindings for the Button." + kb = KeyBindings() + + @kb.add(" ") + @kb.add("enter") + def _(event: E) -> None: + if self.handler is not None: + self.handler() + + return kb + + def __pt_container__(self) -> Container: + return self.window + + +class Frame: + """ + Draw a border around any container, optionally with a title text. + + Changing the title and body of the frame is possible at runtime by + assigning to the `body` and `title` attributes of this class. + + :param body: Another container object. + :param title: Text to be displayed in the top of the frame (can be formatted text). + :param style: Style string to be applied to this widget. + """ + + def __init__( + self, + body: AnyContainer, + title: AnyFormattedText = "", + style: str = "", + width: AnyDimension = None, + height: AnyDimension = None, + key_bindings: KeyBindings | None = None, + modal: bool = False, + ) -> None: + self.title = title + self.body = body + + fill = partial(Window, style="class:frame.border") + style = "class:frame " + style + + top_row_with_title = VSplit( + [ + fill(width=1, height=1, char=Border.TOP_LEFT), + fill(char=Border.HORIZONTAL), + fill(width=1, height=1, char="|"), + # Notice: we use `Template` here, because `self.title` can be an + # `HTML` object for instance. + Label( + lambda: Template(" {} ").format(self.title), + style="class:frame.label", + dont_extend_width=True, + ), + fill(width=1, height=1, char="|"), + fill(char=Border.HORIZONTAL), + fill(width=1, height=1, char=Border.TOP_RIGHT), + ], + height=1, + ) + + top_row_without_title = VSplit( + [ + fill(width=1, height=1, char=Border.TOP_LEFT), + fill(char=Border.HORIZONTAL), + fill(width=1, height=1, char=Border.TOP_RIGHT), + ], + height=1, + ) + + @Condition + def has_title() -> bool: + return bool(self.title) + + self.container = HSplit( + [ + ConditionalContainer(content=top_row_with_title, filter=has_title), + ConditionalContainer(content=top_row_without_title, filter=~has_title), + VSplit( + [ + fill(width=1, char=Border.VERTICAL), + DynamicContainer(lambda: self.body), + fill(width=1, char=Border.VERTICAL), + # Padding is required to make sure that if the content is + # too small, the right frame border is still aligned. + ], + padding=0, + ), + VSplit( + [ + fill(width=1, height=1, char=Border.BOTTOM_LEFT), + fill(char=Border.HORIZONTAL), + fill(width=1, height=1, char=Border.BOTTOM_RIGHT), + ], + # specifying height here will increase the rendering speed. + height=1, + ), + ], + width=width, + height=height, + style=style, + key_bindings=key_bindings, + modal=modal, + ) + + def __pt_container__(self) -> Container: + return self.container + + +class Shadow: + """ + Draw a shadow underneath/behind this container. + (This applies `class:shadow` the the cells under the shadow. The Style + should define the colors for the shadow.) + + :param body: Another container object. + """ + + def __init__(self, body: AnyContainer) -> None: + self.container = FloatContainer( + content=body, + floats=[ + Float( + bottom=-1, + height=1, + left=1, + right=-1, + transparent=True, + content=Window(style="class:shadow"), + ), + Float( + bottom=-1, + top=1, + width=1, + right=-1, + transparent=True, + content=Window(style="class:shadow"), + ), + ], + ) + + def __pt_container__(self) -> Container: + return self.container + + +class Box: + """ + Add padding around a container. + + This also makes sure that the parent can provide more space than required by + the child. This is very useful when wrapping a small element with a fixed + size into a ``VSplit`` or ``HSplit`` object. The ``HSplit`` and ``VSplit`` + try to make sure to adapt respectively the width and height, possibly + shrinking other elements. Wrapping something in a ``Box`` makes it flexible. + + :param body: Another container object. + :param padding: The margin to be used around the body. This can be + overridden by `padding_left`, padding_right`, `padding_top` and + `padding_bottom`. + :param style: A style string. + :param char: Character to be used for filling the space around the body. + (This is supposed to be a character with a terminal width of 1.) + """ + + def __init__( + self, + body: AnyContainer, + padding: AnyDimension = None, + padding_left: AnyDimension = None, + padding_right: AnyDimension = None, + padding_top: AnyDimension = None, + padding_bottom: AnyDimension = None, + width: AnyDimension = None, + height: AnyDimension = None, + style: str = "", + char: None | str | Callable[[], str] = None, + 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.body = body + + self.container = HSplit( + [ + Window(height=self.padding_top, char=char), + VSplit( + [ + Window(width=self.padding_left, char=char), + body, + Window(width=self.padding_right, char=char), + ] + ), + Window(height=self.padding_bottom, char=char), + ], + width=width, + height=height, + style=style, + modal=modal, + key_bindings=None, + ) + + def __pt_container__(self) -> Container: + return self.container + + +_T = TypeVar("_T") + + +class _DialogList(Generic[_T]): + """ + Common code for `RadioList` and `CheckboxList`. + """ + + open_character: str = "" + close_character: str = "" + container_style: str = "" + default_style: str = "" + selected_style: str = "" + checked_style: str = "" + multiple_selection: bool = False + show_scrollbar: bool = True + + def __init__( + self, + values: Sequence[tuple[_T, AnyFormattedText]], + default_values: Sequence[_T] | None = None, + ) -> None: + assert len(values) > 0 + default_values = default_values or [] + + self.values = values + # current_values will be used in multiple_selection, + # current_value will be used otherwise. + keys: list[_T] = [value for (value, _) in values] + self.current_values: list[_T] = [ + value for value in default_values if value in keys + ] + self.current_value: _T = ( + default_values[0] + if len(default_values) and default_values[0] in keys + else values[0][0] + ) + + # Cursor index: take first selected item or first item otherwise. + if len(self.current_values) > 0: + self._selected_index = keys.index(self.current_values[0]) + else: + self._selected_index = 0 + + # Key bindings. + kb = KeyBindings() + + @kb.add("up") + def _up(event: E) -> None: + self._selected_index = max(0, self._selected_index - 1) + + @kb.add("down") + def _down(event: E) -> None: + self._selected_index = min(len(self.values) - 1, self._selected_index + 1) + + @kb.add("pageup") + def _pageup(event: E) -> None: + w = event.app.layout.current_window + if w.render_info: + self._selected_index = max( + 0, self._selected_index - len(w.render_info.displayed_lines) + ) + + @kb.add("pagedown") + def _pagedown(event: E) -> None: + w = event.app.layout.current_window + if w.render_info: + self._selected_index = min( + len(self.values) - 1, + self._selected_index + len(w.render_info.displayed_lines), + ) + + @kb.add("enter") + @kb.add(" ") + def _click(event: E) -> None: + self._handle_enter() + + @kb.add(Keys.Any) + def _find(event: E) -> None: + # We first check values after the selected value, then all values. + values = list(self.values) + for value in values[self._selected_index + 1 :] + values: + text = fragment_list_to_text(to_formatted_text(value[1])).lower() + + if text.startswith(event.data.lower()): + self._selected_index = self.values.index(value) + return + + # Control and window. + self.control = FormattedTextControl( + self._get_text_fragments, key_bindings=kb, focusable=True + ) + + self.window = Window( + content=self.control, + style=self.container_style, + right_margins=[ + ConditionalMargin( + margin=ScrollbarMargin(display_arrows=True), + filter=Condition(lambda: self.show_scrollbar), + ), + ], + dont_extend_height=True, + ) + + def _handle_enter(self) -> None: + if self.multiple_selection: + val = self.values[self._selected_index][0] + if val in self.current_values: + self.current_values.remove(val) + else: + self.current_values.append(val) + else: + self.current_value = self.values[self._selected_index][0] + + def _get_text_fragments(self) -> StyleAndTextTuples: + def mouse_handler(mouse_event: MouseEvent) -> None: + """ + Set `_selected_index` and `current_value` according to the y + position of the mouse click event. + """ + if mouse_event.event_type == MouseEventType.MOUSE_UP: + self._selected_index = mouse_event.position.y + self._handle_enter() + + result: StyleAndTextTuples = [] + for i, value in enumerate(self.values): + if self.multiple_selection: + checked = value[0] in self.current_values + else: + checked = value[0] == self.current_value + selected = i == self._selected_index + + style = "" + if checked: + style += " " + self.checked_style + if selected: + style += " " + self.selected_style + + result.append((style, self.open_character)) + + if selected: + result.append(("[SetCursorPosition]", "")) + + if checked: + result.append((style, "*")) + else: + result.append((style, " ")) + + result.append((style, self.close_character)) + result.append((self.default_style, " ")) + result.extend(to_formatted_text(value[1], style=self.default_style)) + result.append(("", "\n")) + + # Add mouse handler to all fragments. + for i in range(len(result)): + result[i] = (result[i][0], result[i][1], mouse_handler) + + result.pop() # Remove last newline. + return result + + def __pt_container__(self) -> Container: + return self.window + + +class RadioList(_DialogList[_T]): + """ + List of radio buttons. Only one can be checked at the same time. + + :param values: List of (value, label) tuples. + """ + + open_character = "(" + close_character = ")" + container_style = "class:radio-list" + default_style = "class:radio" + selected_style = "class:radio-selected" + checked_style = "class:radio-checked" + multiple_selection = False + + def __init__( + self, + values: Sequence[tuple[_T, AnyFormattedText]], + default: _T | None = None, + ) -> None: + if default is None: + default_values = None + else: + default_values = [default] + + super().__init__(values, default_values=default_values) + + +class CheckboxList(_DialogList[_T]): + """ + List of checkbox buttons. Several can be checked at the same time. + + :param values: List of (value, label) tuples. + """ + + open_character = "[" + close_character = "]" + container_style = "class:checkbox-list" + default_style = "class:checkbox" + selected_style = "class:checkbox-selected" + checked_style = "class:checkbox-checked" + multiple_selection = True + + +class Checkbox(CheckboxList[str]): + """Backward compatibility util: creates a 1-sized CheckboxList + + :param text: the text + """ + + show_scrollbar = False + + def __init__(self, text: AnyFormattedText = "", checked: bool = False) -> None: + values = [("value", text)] + super().__init__(values=values) + self.checked = checked + + @property + def checked(self) -> bool: + return "value" in self.current_values + + @checked.setter + def checked(self, value: bool) -> None: + if value: + self.current_values = ["value"] + else: + self.current_values = [] + + +class VerticalLine: + """ + A simple vertical line with a width of 1. + """ + + def __init__(self) -> None: + self.window = Window( + char=Border.VERTICAL, style="class:line,vertical-line", width=1 + ) + + def __pt_container__(self) -> Container: + return self.window + + +class HorizontalLine: + """ + A simple horizontal line with a height of 1. + """ + + def __init__(self) -> None: + self.window = Window( + char=Border.HORIZONTAL, style="class:line,horizontal-line", height=1 + ) + + def __pt_container__(self) -> Container: + return self.window + + +class ProgressBar: + def __init__(self) -> None: + self._percentage = 60 + + self.label = Label("60%") + self.container = FloatContainer( + content=Window(height=1), + floats=[ + # We first draw the label, then the actual progress bar. Right + # now, this is the only way to have the colors of the progress + # bar appear on top of the label. The problem is that our label + # can't be part of any `Window` below. + Float(content=self.label, top=0, bottom=0), + Float( + left=0, + top=0, + right=0, + bottom=0, + content=VSplit( + [ + Window( + style="class:progress-bar.used", + width=lambda: D(weight=int(self._percentage)), + ), + Window( + style="class:progress-bar", + width=lambda: D(weight=int(100 - self._percentage)), + ), + ] + ), + ), + ], + ) + + @property + def percentage(self) -> int: + return self._percentage + + @percentage.setter + def percentage(self, value: int) -> None: + self._percentage = value + self.label.text = f"{value}%" + + def __pt_container__(self) -> Container: + return self.container diff --git a/src/prompt_toolkit/widgets/dialogs.py b/src/prompt_toolkit/widgets/dialogs.py new file mode 100644 index 0000000..c47c15b --- /dev/null +++ b/src/prompt_toolkit/widgets/dialogs.py @@ -0,0 +1,107 @@ +""" +Collection of reusable components for building full screen applications. +""" +from __future__ import annotations + +from typing import Sequence + +from prompt_toolkit.filters import has_completions, has_focus +from prompt_toolkit.formatted_text import AnyFormattedText +from prompt_toolkit.key_binding.bindings.focus import focus_next, focus_previous +from prompt_toolkit.key_binding.key_bindings import KeyBindings +from prompt_toolkit.layout.containers import ( + AnyContainer, + DynamicContainer, + HSplit, + VSplit, +) +from prompt_toolkit.layout.dimension import AnyDimension +from prompt_toolkit.layout.dimension import Dimension as D + +from .base import Box, Button, Frame, Shadow + +__all__ = [ + "Dialog", +] + + +class Dialog: + """ + Simple dialog window. This is the base for input dialogs, message dialogs + and confirmation dialogs. + + Changing the title and body of the dialog is possible at runtime by + assigning to the `body` and `title` attributes of this class. + + :param body: Child container object. + :param title: Text to be displayed in the heading of the dialog. + :param buttons: A list of `Button` widgets, displayed at the bottom. + """ + + def __init__( + self, + body: AnyContainer, + title: AnyFormattedText = "", + buttons: Sequence[Button] | None = None, + modal: bool = True, + width: AnyDimension = None, + with_background: bool = False, + ) -> None: + self.body = body + self.title = title + + buttons = buttons or [] + + # When a button is selected, handle left/right key bindings. + buttons_kb = KeyBindings() + if len(buttons) > 1: + first_selected = has_focus(buttons[0]) + last_selected = has_focus(buttons[-1]) + + buttons_kb.add("left", filter=~first_selected)(focus_previous) + buttons_kb.add("right", filter=~last_selected)(focus_next) + + frame_body: AnyContainer + if buttons: + frame_body = HSplit( + [ + # Add optional padding around the body. + Box( + body=DynamicContainer(lambda: self.body), + padding=D(preferred=1, max=1), + padding_bottom=0, + ), + # The buttons. + Box( + body=VSplit(buttons, padding=1, key_bindings=buttons_kb), + height=D(min=1, max=3, preferred=3), + ), + ] + ) + else: + frame_body = body + + # Key bindings for whole dialog. + kb = KeyBindings() + kb.add("tab", filter=~has_completions)(focus_next) + kb.add("s-tab", filter=~has_completions)(focus_previous) + + frame = Shadow( + body=Frame( + title=lambda: self.title, + body=frame_body, + style="class:dialog.body", + width=(None if with_background is None else width), + key_bindings=kb, + modal=modal, + ) + ) + + self.container: Box | Shadow + if with_background: + self.container = Box(body=frame, style="class:dialog", width=width) + else: + self.container = frame + + def __pt_container__(self) -> AnyContainer: + return self.container diff --git a/src/prompt_toolkit/widgets/menus.py b/src/prompt_toolkit/widgets/menus.py new file mode 100644 index 0000000..c574c06 --- /dev/null +++ b/src/prompt_toolkit/widgets/menus.py @@ -0,0 +1,374 @@ +from __future__ import annotations + +from typing import Callable, Iterable, Sequence + +from prompt_toolkit.application.current import get_app +from prompt_toolkit.filters import Condition +from prompt_toolkit.formatted_text.base import OneStyleAndTextTuple, StyleAndTextTuples +from prompt_toolkit.key_binding.key_bindings import KeyBindings, KeyBindingsBase +from prompt_toolkit.key_binding.key_processor import KeyPressEvent +from prompt_toolkit.keys import Keys +from prompt_toolkit.layout.containers import ( + AnyContainer, + ConditionalContainer, + Container, + Float, + FloatContainer, + HSplit, + Window, +) +from prompt_toolkit.layout.controls import FormattedTextControl +from prompt_toolkit.mouse_events import MouseEvent, MouseEventType +from prompt_toolkit.utils import get_cwidth +from prompt_toolkit.widgets import Shadow + +from .base import Border + +__all__ = [ + "MenuContainer", + "MenuItem", +] + +E = KeyPressEvent + + +class MenuContainer: + """ + :param floats: List of extra Float objects to display. + :param menu_items: List of `MenuItem` objects. + """ + + def __init__( + self, + body: AnyContainer, + menu_items: list[MenuItem], + floats: list[Float] | None = None, + key_bindings: KeyBindingsBase | None = None, + ) -> None: + self.body = body + self.menu_items = menu_items + self.selected_menu = [0] + + # Key bindings. + kb = KeyBindings() + + @Condition + def in_main_menu() -> bool: + return len(self.selected_menu) == 1 + + @Condition + def in_sub_menu() -> bool: + return len(self.selected_menu) > 1 + + # Navigation through the main menu. + + @kb.add("left", filter=in_main_menu) + def _left(event: E) -> None: + self.selected_menu[0] = max(0, self.selected_menu[0] - 1) + + @kb.add("right", filter=in_main_menu) + def _right(event: E) -> None: + self.selected_menu[0] = min( + len(self.menu_items) - 1, self.selected_menu[0] + 1 + ) + + @kb.add("down", filter=in_main_menu) + def _down(event: E) -> None: + self.selected_menu.append(0) + + @kb.add("c-c", filter=in_main_menu) + @kb.add("c-g", filter=in_main_menu) + def _cancel(event: E) -> None: + "Leave menu." + event.app.layout.focus_last() + + # Sub menu navigation. + + @kb.add("left", filter=in_sub_menu) + @kb.add("c-g", filter=in_sub_menu) + @kb.add("c-c", filter=in_sub_menu) + def _back(event: E) -> None: + "Go back to parent menu." + if len(self.selected_menu) > 1: + self.selected_menu.pop() + + @kb.add("right", filter=in_sub_menu) + def _submenu(event: E) -> None: + "go into sub menu." + if self._get_menu(len(self.selected_menu) - 1).children: + self.selected_menu.append(0) + + # If This item does not have a sub menu. Go up in the parent menu. + elif ( + len(self.selected_menu) == 2 + and self.selected_menu[0] < len(self.menu_items) - 1 + ): + self.selected_menu = [ + min(len(self.menu_items) - 1, self.selected_menu[0] + 1) + ] + if self.menu_items[self.selected_menu[0]].children: + self.selected_menu.append(0) + + @kb.add("up", filter=in_sub_menu) + def _up_in_submenu(event: E) -> None: + "Select previous (enabled) menu item or return to main menu." + # Look for previous enabled items in this sub menu. + menu = self._get_menu(len(self.selected_menu) - 2) + index = self.selected_menu[-1] + + previous_indexes = [ + i + for i, item in enumerate(menu.children) + if i < index and not item.disabled + ] + + if previous_indexes: + self.selected_menu[-1] = previous_indexes[-1] + elif len(self.selected_menu) == 2: + # Return to main menu. + self.selected_menu.pop() + + @kb.add("down", filter=in_sub_menu) + def _down_in_submenu(event: E) -> None: + "Select next (enabled) menu item." + menu = self._get_menu(len(self.selected_menu) - 2) + index = self.selected_menu[-1] + + next_indexes = [ + i + for i, item in enumerate(menu.children) + if i > index and not item.disabled + ] + + if next_indexes: + self.selected_menu[-1] = next_indexes[0] + + @kb.add("enter") + def _click(event: E) -> None: + "Click the selected menu item." + item = self._get_menu(len(self.selected_menu) - 1) + if item.handler: + event.app.layout.focus_last() + item.handler() + + # Controls. + self.control = FormattedTextControl( + self._get_menu_fragments, key_bindings=kb, focusable=True, show_cursor=False + ) + + self.window = Window(height=1, content=self.control, style="class:menu-bar") + + submenu = self._submenu(0) + submenu2 = self._submenu(1) + submenu3 = self._submenu(2) + + @Condition + def has_focus() -> bool: + return get_app().layout.current_window == self.window + + self.container = FloatContainer( + content=HSplit( + [ + # The titlebar. + self.window, + # The 'body', like defined above. + body, + ] + ), + floats=[ + Float( + xcursor=True, + ycursor=True, + content=ConditionalContainer( + content=Shadow(body=submenu), filter=has_focus + ), + ), + Float( + attach_to_window=submenu, + xcursor=True, + ycursor=True, + allow_cover_cursor=True, + content=ConditionalContainer( + content=Shadow(body=submenu2), + filter=has_focus + & Condition(lambda: len(self.selected_menu) >= 1), + ), + ), + Float( + attach_to_window=submenu2, + xcursor=True, + ycursor=True, + allow_cover_cursor=True, + content=ConditionalContainer( + content=Shadow(body=submenu3), + filter=has_focus + & Condition(lambda: len(self.selected_menu) >= 2), + ), + ), + # -- + ] + + (floats or []), + key_bindings=key_bindings, + ) + + def _get_menu(self, level: int) -> MenuItem: + menu = self.menu_items[self.selected_menu[0]] + + for i, index in enumerate(self.selected_menu[1:]): + if i < level: + try: + menu = menu.children[index] + except IndexError: + return MenuItem("debug") + + return menu + + def _get_menu_fragments(self) -> StyleAndTextTuples: + focused = get_app().layout.has_focus(self.window) + + # This is called during the rendering. When we discover that this + # widget doesn't have the focus anymore. Reset menu state. + if not focused: + self.selected_menu = [0] + + # Generate text fragments for the main menu. + def one_item(i: int, item: MenuItem) -> Iterable[OneStyleAndTextTuple]: + def mouse_handler(mouse_event: MouseEvent) -> None: + hover = mouse_event.event_type == MouseEventType.MOUSE_MOVE + if ( + mouse_event.event_type == MouseEventType.MOUSE_DOWN + or hover + and focused + ): + # Toggle focus. + app = get_app() + if not hover: + if app.layout.has_focus(self.window): + if self.selected_menu == [i]: + app.layout.focus_last() + else: + app.layout.focus(self.window) + self.selected_menu = [i] + + yield ("class:menu-bar", " ", mouse_handler) + if i == self.selected_menu[0] and focused: + yield ("[SetMenuPosition]", "", mouse_handler) + style = "class:menu-bar.selected-item" + else: + style = "class:menu-bar" + yield style, item.text, mouse_handler + + result: StyleAndTextTuples = [] + for i, item in enumerate(self.menu_items): + result.extend(one_item(i, item)) + + return result + + def _submenu(self, level: int = 0) -> Window: + def get_text_fragments() -> StyleAndTextTuples: + result: StyleAndTextTuples = [] + if level < len(self.selected_menu): + menu = self._get_menu(level) + if menu.children: + result.append(("class:menu", Border.TOP_LEFT)) + result.append(("class:menu", Border.HORIZONTAL * (menu.width + 4))) + result.append(("class:menu", Border.TOP_RIGHT)) + result.append(("", "\n")) + try: + selected_item = self.selected_menu[level + 1] + except IndexError: + selected_item = -1 + + def one_item( + i: int, item: MenuItem + ) -> Iterable[OneStyleAndTextTuple]: + def mouse_handler(mouse_event: MouseEvent) -> None: + if item.disabled: + # The arrow keys can't interact with menu items that are disabled. + # The mouse shouldn't be able to either. + return + hover = mouse_event.event_type == MouseEventType.MOUSE_MOVE + if ( + mouse_event.event_type == MouseEventType.MOUSE_UP + or hover + ): + app = get_app() + if not hover and item.handler: + app.layout.focus_last() + item.handler() + else: + self.selected_menu = self.selected_menu[ + : level + 1 + ] + [i] + + if i == selected_item: + yield ("[SetCursorPosition]", "") + style = "class:menu-bar.selected-item" + else: + style = "" + + yield ("class:menu", Border.VERTICAL) + if item.text == "-": + yield ( + style + "class:menu-border", + f"{Border.HORIZONTAL * (menu.width + 3)}", + mouse_handler, + ) + else: + yield ( + style, + f" {item.text}".ljust(menu.width + 3), + mouse_handler, + ) + + if item.children: + yield (style, ">", mouse_handler) + else: + yield (style, " ", mouse_handler) + + if i == selected_item: + yield ("[SetMenuPosition]", "") + yield ("class:menu", Border.VERTICAL) + + yield ("", "\n") + + for i, item in enumerate(menu.children): + result.extend(one_item(i, item)) + + result.append(("class:menu", Border.BOTTOM_LEFT)) + result.append(("class:menu", Border.HORIZONTAL * (menu.width + 4))) + result.append(("class:menu", Border.BOTTOM_RIGHT)) + return result + + return Window(FormattedTextControl(get_text_fragments), style="class:menu") + + @property + def floats(self) -> list[Float] | None: + return self.container.floats + + def __pt_container__(self) -> Container: + return self.container + + +class MenuItem: + def __init__( + self, + text: str = "", + handler: Callable[[], None] | None = None, + children: list[MenuItem] | None = None, + shortcut: Sequence[Keys | str] | None = None, + disabled: bool = False, + ) -> None: + self.text = text + self.handler = handler + self.children = children or [] + self.shortcut = shortcut + self.disabled = disabled + self.selected_item = 0 + + @property + def width(self) -> int: + if self.children: + return max(get_cwidth(c.text) for c in self.children) + else: + return 0 diff --git a/src/prompt_toolkit/widgets/toolbars.py b/src/prompt_toolkit/widgets/toolbars.py new file mode 100644 index 0000000..deddf15 --- /dev/null +++ b/src/prompt_toolkit/widgets/toolbars.py @@ -0,0 +1,374 @@ +from __future__ import annotations + +from typing import Any + +from prompt_toolkit.application.current import get_app +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.enums import SYSTEM_BUFFER +from prompt_toolkit.filters import ( + Condition, + FilterOrBool, + emacs_mode, + has_arg, + has_completions, + has_focus, + has_validation_error, + to_filter, + vi_mode, + vi_navigation_mode, +) +from prompt_toolkit.formatted_text import ( + AnyFormattedText, + StyleAndTextTuples, + fragment_list_len, + to_formatted_text, +) +from prompt_toolkit.key_binding.key_bindings import ( + ConditionalKeyBindings, + KeyBindings, + KeyBindingsBase, + merge_key_bindings, +) +from prompt_toolkit.key_binding.key_processor import KeyPressEvent +from prompt_toolkit.key_binding.vi_state import InputMode +from prompt_toolkit.keys import Keys +from prompt_toolkit.layout.containers import ConditionalContainer, Container, Window +from prompt_toolkit.layout.controls import ( + BufferControl, + FormattedTextControl, + SearchBufferControl, + UIContent, + UIControl, +) +from prompt_toolkit.layout.dimension import Dimension +from prompt_toolkit.layout.processors import BeforeInput +from prompt_toolkit.lexers import SimpleLexer +from prompt_toolkit.search import SearchDirection + +__all__ = [ + "ArgToolbar", + "CompletionsToolbar", + "FormattedTextToolbar", + "SearchToolbar", + "SystemToolbar", + "ValidationToolbar", +] + +E = KeyPressEvent + + +class FormattedTextToolbar(Window): + def __init__(self, text: AnyFormattedText, style: str = "", **kw: Any) -> None: + # Note: The style needs to be applied to the toolbar as a whole, not + # just the `FormattedTextControl`. + super().__init__( + FormattedTextControl(text, **kw), + style=style, + dont_extend_height=True, + height=Dimension(min=1), + ) + + +class SystemToolbar: + """ + Toolbar for a system prompt. + + :param prompt: Prompt to be displayed to the user. + """ + + def __init__( + self, + prompt: AnyFormattedText = "Shell command: ", + enable_global_bindings: FilterOrBool = True, + ) -> None: + self.prompt = prompt + self.enable_global_bindings = to_filter(enable_global_bindings) + + self.system_buffer = Buffer(name=SYSTEM_BUFFER) + + self._bindings = self._build_key_bindings() + + self.buffer_control = BufferControl( + buffer=self.system_buffer, + lexer=SimpleLexer(style="class:system-toolbar.text"), + input_processors=[ + BeforeInput(lambda: self.prompt, style="class:system-toolbar") + ], + key_bindings=self._bindings, + ) + + self.window = Window( + self.buffer_control, height=1, style="class:system-toolbar" + ) + + self.container = ConditionalContainer( + content=self.window, filter=has_focus(self.system_buffer) + ) + + def _get_display_before_text(self) -> StyleAndTextTuples: + return [ + ("class:system-toolbar", "Shell command: "), + ("class:system-toolbar.text", self.system_buffer.text), + ("", "\n"), + ] + + def _build_key_bindings(self) -> KeyBindingsBase: + focused = has_focus(self.system_buffer) + + # Emacs + emacs_bindings = KeyBindings() + handle = emacs_bindings.add + + @handle("escape", filter=focused) + @handle("c-g", filter=focused) + @handle("c-c", filter=focused) + def _cancel(event: E) -> None: + "Hide system prompt." + self.system_buffer.reset() + event.app.layout.focus_last() + + @handle("enter", filter=focused) + async def _accept(event: E) -> None: + "Run system command." + await event.app.run_system_command( + self.system_buffer.text, + display_before_text=self._get_display_before_text(), + ) + self.system_buffer.reset(append_to_history=True) + event.app.layout.focus_last() + + # Vi. + vi_bindings = KeyBindings() + handle = vi_bindings.add + + @handle("escape", filter=focused) + @handle("c-c", filter=focused) + def _cancel_vi(event: E) -> None: + "Hide system prompt." + event.app.vi_state.input_mode = InputMode.NAVIGATION + self.system_buffer.reset() + event.app.layout.focus_last() + + @handle("enter", filter=focused) + async def _accept_vi(event: E) -> None: + "Run system command." + event.app.vi_state.input_mode = InputMode.NAVIGATION + await event.app.run_system_command( + self.system_buffer.text, + display_before_text=self._get_display_before_text(), + ) + self.system_buffer.reset(append_to_history=True) + event.app.layout.focus_last() + + # Global bindings. (Listen to these bindings, even when this widget is + # not focussed.) + global_bindings = KeyBindings() + handle = global_bindings.add + + @handle(Keys.Escape, "!", filter=~focused & emacs_mode, is_global=True) + def _focus_me(event: E) -> None: + "M-'!' will focus this user control." + event.app.layout.focus(self.window) + + @handle("!", filter=~focused & vi_mode & vi_navigation_mode, is_global=True) + def _focus_me_vi(event: E) -> None: + "Focus." + event.app.vi_state.input_mode = InputMode.INSERT + event.app.layout.focus(self.window) + + return merge_key_bindings( + [ + ConditionalKeyBindings(emacs_bindings, emacs_mode), + ConditionalKeyBindings(vi_bindings, vi_mode), + ConditionalKeyBindings(global_bindings, self.enable_global_bindings), + ] + ) + + def __pt_container__(self) -> Container: + return self.container + + +class ArgToolbar: + def __init__(self) -> None: + def get_formatted_text() -> StyleAndTextTuples: + arg = get_app().key_processor.arg or "" + if arg == "-": + arg = "-1" + + return [ + ("class:arg-toolbar", "Repeat: "), + ("class:arg-toolbar.text", arg), + ] + + self.window = Window(FormattedTextControl(get_formatted_text), height=1) + + self.container = ConditionalContainer(content=self.window, filter=has_arg) + + def __pt_container__(self) -> Container: + return self.container + + +class SearchToolbar: + """ + :param vi_mode: Display '/' and '?' instead of I-search. + :param ignore_case: Search case insensitive. + """ + + def __init__( + self, + search_buffer: Buffer | None = None, + vi_mode: bool = False, + text_if_not_searching: AnyFormattedText = "", + forward_search_prompt: AnyFormattedText = "I-search: ", + backward_search_prompt: AnyFormattedText = "I-search backward: ", + ignore_case: FilterOrBool = False, + ) -> None: + if search_buffer is None: + search_buffer = Buffer() + + @Condition + def is_searching() -> bool: + return self.control in get_app().layout.search_links + + def get_before_input() -> AnyFormattedText: + if not is_searching(): + return text_if_not_searching + elif ( + self.control.searcher_search_state.direction == SearchDirection.BACKWARD + ): + return "?" if vi_mode else backward_search_prompt + else: + return "/" if vi_mode else forward_search_prompt + + self.search_buffer = search_buffer + + self.control = SearchBufferControl( + buffer=search_buffer, + input_processors=[ + BeforeInput(get_before_input, style="class:search-toolbar.prompt") + ], + lexer=SimpleLexer(style="class:search-toolbar.text"), + ignore_case=ignore_case, + ) + + self.container = ConditionalContainer( + content=Window(self.control, height=1, style="class:search-toolbar"), + filter=is_searching, + ) + + def __pt_container__(self) -> Container: + return self.container + + +class _CompletionsToolbarControl(UIControl): + def create_content(self, width: int, height: int) -> UIContent: + all_fragments: StyleAndTextTuples = [] + + complete_state = get_app().current_buffer.complete_state + if complete_state: + completions = complete_state.completions + index = complete_state.complete_index # Can be None! + + # Width of the completions without the left/right arrows in the margins. + content_width = width - 6 + + # Booleans indicating whether we stripped from the left/right + cut_left = False + cut_right = False + + # Create Menu content. + fragments: StyleAndTextTuples = [] + + for i, c in enumerate(completions): + # When there is no more place for the next completion + if fragment_list_len(fragments) + len(c.display_text) >= content_width: + # If the current one was not yet displayed, page to the next sequence. + if i <= (index or 0): + fragments = [] + cut_left = True + # If the current one is visible, stop here. + else: + cut_right = True + break + + fragments.extend( + to_formatted_text( + c.display_text, + style=( + "class:completion-toolbar.completion.current" + if i == index + else "class:completion-toolbar.completion" + ), + ) + ) + fragments.append(("", " ")) + + # Extend/strip until the content width. + fragments.append(("", " " * (content_width - fragment_list_len(fragments)))) + fragments = fragments[:content_width] + + # Return fragments + all_fragments.append(("", " ")) + all_fragments.append( + ("class:completion-toolbar.arrow", "<" if cut_left else " ") + ) + all_fragments.append(("", " ")) + + all_fragments.extend(fragments) + + all_fragments.append(("", " ")) + all_fragments.append( + ("class:completion-toolbar.arrow", ">" if cut_right else " ") + ) + all_fragments.append(("", " ")) + + def get_line(i: int) -> StyleAndTextTuples: + return all_fragments + + return UIContent(get_line=get_line, line_count=1) + + +class CompletionsToolbar: + def __init__(self) -> None: + self.container = ConditionalContainer( + content=Window( + _CompletionsToolbarControl(), height=1, style="class:completion-toolbar" + ), + filter=has_completions, + ) + + def __pt_container__(self) -> Container: + return self.container + + +class ValidationToolbar: + def __init__(self, show_position: bool = False) -> None: + def get_formatted_text() -> StyleAndTextTuples: + buff = get_app().current_buffer + + if buff.validation_error: + row, column = buff.document.translate_index_to_position( + buff.validation_error.cursor_position + ) + + if show_position: + text = "{} (line={} column={})".format( + buff.validation_error.message, + row + 1, + column + 1, + ) + else: + text = buff.validation_error.message + + return [("class:validation-toolbar", text)] + else: + return [] + + self.control = FormattedTextControl(get_formatted_text) + + self.container = ConditionalContainer( + content=Window(self.control, height=1), filter=has_validation_error + ) + + def __pt_container__(self) -> Container: + return self.container diff --git a/src/prompt_toolkit/win32_types.py b/src/prompt_toolkit/win32_types.py new file mode 100644 index 0000000..79283b8 --- /dev/null +++ b/src/prompt_toolkit/win32_types.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +from ctypes import Structure, Union, c_char, c_long, c_short, c_ulong +from ctypes.wintypes import BOOL, DWORD, LPVOID, WCHAR, WORD +from typing import TYPE_CHECKING + +# Input/Output standard device numbers. Note that these are not handle objects. +# It's the `windll.kernel32.GetStdHandle` system call that turns them into a +# real handle object. +STD_INPUT_HANDLE = c_ulong(-10) +STD_OUTPUT_HANDLE = c_ulong(-11) +STD_ERROR_HANDLE = c_ulong(-12) + + +class COORD(Structure): + """ + Struct in wincon.h + http://msdn.microsoft.com/en-us/library/windows/desktop/ms682119(v=vs.85).aspx + """ + + if TYPE_CHECKING: + X: int + Y: int + + _fields_ = [ + ("X", c_short), # Short + ("Y", c_short), # Short + ] + + def __repr__(self) -> str: + return "{}(X={!r}, Y={!r}, type_x={!r}, type_y={!r})".format( + self.__class__.__name__, + self.X, + self.Y, + type(self.X), + type(self.Y), + ) + + +class UNICODE_OR_ASCII(Union): + if TYPE_CHECKING: + AsciiChar: bytes + UnicodeChar: str + + _fields_ = [ + ("AsciiChar", c_char), + ("UnicodeChar", WCHAR), + ] + + +class KEY_EVENT_RECORD(Structure): + """ + http://msdn.microsoft.com/en-us/library/windows/desktop/ms684166(v=vs.85).aspx + """ + + if TYPE_CHECKING: + KeyDown: int + RepeatCount: int + VirtualKeyCode: int + VirtualScanCode: int + uChar: UNICODE_OR_ASCII + ControlKeyState: int + + _fields_ = [ + ("KeyDown", c_long), # bool + ("RepeatCount", c_short), # word + ("VirtualKeyCode", c_short), # word + ("VirtualScanCode", c_short), # word + ("uChar", UNICODE_OR_ASCII), # Unicode or ASCII. + ("ControlKeyState", c_long), # double word + ] + + +class MOUSE_EVENT_RECORD(Structure): + """ + http://msdn.microsoft.com/en-us/library/windows/desktop/ms684239(v=vs.85).aspx + """ + + if TYPE_CHECKING: + MousePosition: COORD + ButtonState: int + ControlKeyState: int + EventFlags: int + + _fields_ = [ + ("MousePosition", COORD), + ("ButtonState", c_long), # dword + ("ControlKeyState", c_long), # dword + ("EventFlags", c_long), # dword + ] + + +class WINDOW_BUFFER_SIZE_RECORD(Structure): + """ + http://msdn.microsoft.com/en-us/library/windows/desktop/ms687093(v=vs.85).aspx + """ + + if TYPE_CHECKING: + Size: COORD + + _fields_ = [("Size", COORD)] + + +class MENU_EVENT_RECORD(Structure): + """ + http://msdn.microsoft.com/en-us/library/windows/desktop/ms684213(v=vs.85).aspx + """ + + if TYPE_CHECKING: + CommandId: int + + _fields_ = [("CommandId", c_long)] # uint + + +class FOCUS_EVENT_RECORD(Structure): + """ + http://msdn.microsoft.com/en-us/library/windows/desktop/ms683149(v=vs.85).aspx + """ + + if TYPE_CHECKING: + SetFocus: int + + _fields_ = [("SetFocus", c_long)] # bool + + +class EVENT_RECORD(Union): + if TYPE_CHECKING: + KeyEvent: KEY_EVENT_RECORD + MouseEvent: MOUSE_EVENT_RECORD + WindowBufferSizeEvent: WINDOW_BUFFER_SIZE_RECORD + MenuEvent: MENU_EVENT_RECORD + FocusEvent: FOCUS_EVENT_RECORD + + _fields_ = [ + ("KeyEvent", KEY_EVENT_RECORD), + ("MouseEvent", MOUSE_EVENT_RECORD), + ("WindowBufferSizeEvent", WINDOW_BUFFER_SIZE_RECORD), + ("MenuEvent", MENU_EVENT_RECORD), + ("FocusEvent", FOCUS_EVENT_RECORD), + ] + + +class INPUT_RECORD(Structure): + """ + http://msdn.microsoft.com/en-us/library/windows/desktop/ms683499(v=vs.85).aspx + """ + + if TYPE_CHECKING: + EventType: int + Event: EVENT_RECORD + + _fields_ = [("EventType", c_short), ("Event", EVENT_RECORD)] # word # Union. + + +EventTypes = { + 1: "KeyEvent", + 2: "MouseEvent", + 4: "WindowBufferSizeEvent", + 8: "MenuEvent", + 16: "FocusEvent", +} + + +class SMALL_RECT(Structure): + """struct in wincon.h.""" + + if TYPE_CHECKING: + Left: int + Top: int + Right: int + Bottom: int + + _fields_ = [ + ("Left", c_short), + ("Top", c_short), + ("Right", c_short), + ("Bottom", c_short), + ] + + +class CONSOLE_SCREEN_BUFFER_INFO(Structure): + """struct in wincon.h.""" + + if TYPE_CHECKING: + dwSize: COORD + dwCursorPosition: COORD + wAttributes: int + srWindow: SMALL_RECT + dwMaximumWindowSize: COORD + + _fields_ = [ + ("dwSize", COORD), + ("dwCursorPosition", COORD), + ("wAttributes", WORD), + ("srWindow", SMALL_RECT), + ("dwMaximumWindowSize", COORD), + ] + + def __repr__(self) -> str: + return "CONSOLE_SCREEN_BUFFER_INFO({!r},{!r},{!r},{!r},{!r},{!r},{!r},{!r},{!r},{!r},{!r})".format( + self.dwSize.Y, + self.dwSize.X, + self.dwCursorPosition.Y, + self.dwCursorPosition.X, + self.wAttributes, + self.srWindow.Top, + self.srWindow.Left, + self.srWindow.Bottom, + self.srWindow.Right, + self.dwMaximumWindowSize.Y, + self.dwMaximumWindowSize.X, + ) + + +class SECURITY_ATTRIBUTES(Structure): + """ + http://msdn.microsoft.com/en-us/library/windows/desktop/aa379560(v=vs.85).aspx + """ + + if TYPE_CHECKING: + nLength: int + lpSecurityDescriptor: int + bInheritHandle: int # BOOL comes back as 'int'. + + _fields_ = [ + ("nLength", DWORD), + ("lpSecurityDescriptor", LPVOID), + ("bInheritHandle", BOOL), + ] diff --git a/tests/test_async_generator.py b/tests/test_async_generator.py new file mode 100644 index 0000000..8c95f8c --- /dev/null +++ b/tests/test_async_generator.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from asyncio import run + +from prompt_toolkit.eventloop import generator_to_async_generator + + +def _sync_generator(): + yield 1 + yield 10 + + +def test_generator_to_async_generator(): + """ + Test conversion of sync to async generator. + This should run the synchronous parts in a background thread. + """ + async_gen = generator_to_async_generator(_sync_generator) + + items = [] + + async def consume_async_generator(): + async for item in async_gen: + items.append(item) + + # Run the event loop until all items are collected. + run(consume_async_generator()) + assert items == [1, 10] diff --git a/tests/test_buffer.py b/tests/test_buffer.py new file mode 100644 index 0000000..e636137 --- /dev/null +++ b/tests/test_buffer.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import pytest + +from prompt_toolkit.buffer import Buffer + + +@pytest.fixture +def _buffer(): + buff = Buffer() + return buff + + +def test_initial(_buffer): + assert _buffer.text == "" + assert _buffer.cursor_position == 0 + + +def test_insert_text(_buffer): + _buffer.insert_text("some_text") + assert _buffer.text == "some_text" + assert _buffer.cursor_position == len("some_text") + + +def test_cursor_movement(_buffer): + _buffer.insert_text("some_text") + _buffer.cursor_left() + _buffer.cursor_left() + _buffer.cursor_left() + _buffer.cursor_right() + _buffer.insert_text("A") + + assert _buffer.text == "some_teAxt" + assert _buffer.cursor_position == len("some_teA") + + +def test_backspace(_buffer): + _buffer.insert_text("some_text") + _buffer.cursor_left() + _buffer.cursor_left() + _buffer.delete_before_cursor() + + assert _buffer.text == "some_txt" + assert _buffer.cursor_position == len("some_t") + + +def test_cursor_up(_buffer): + # Cursor up to a line thats longer. + _buffer.insert_text("long line1\nline2") + _buffer.cursor_up() + + assert _buffer.document.cursor_position == 5 + + # Going up when already at the top. + _buffer.cursor_up() + assert _buffer.document.cursor_position == 5 + + # Going up to a line that's shorter. + _buffer.reset() + _buffer.insert_text("line1\nlong line2") + + _buffer.cursor_up() + assert _buffer.document.cursor_position == 5 + + +def test_cursor_down(_buffer): + _buffer.insert_text("line1\nline2") + _buffer.cursor_position = 3 + + # Normally going down + _buffer.cursor_down() + assert _buffer.document.cursor_position == len("line1\nlin") + + # Going down to a line that's shorter. + _buffer.reset() + _buffer.insert_text("long line1\na\nb") + _buffer.cursor_position = 3 + + _buffer.cursor_down() + assert _buffer.document.cursor_position == len("long line1\na") + + +def test_join_next_line(_buffer): + _buffer.insert_text("line1\nline2\nline3") + _buffer.cursor_up() + _buffer.join_next_line() + + assert _buffer.text == "line1\nline2 line3" + + # Test when there is no '\n' in the text + _buffer.reset() + _buffer.insert_text("line1") + _buffer.cursor_position = 0 + _buffer.join_next_line() + + assert _buffer.text == "line1" + + +def test_newline(_buffer): + _buffer.insert_text("hello world") + _buffer.newline() + + assert _buffer.text == "hello world\n" + + +def test_swap_characters_before_cursor(_buffer): + _buffer.insert_text("hello world") + _buffer.cursor_left() + _buffer.cursor_left() + _buffer.swap_characters_before_cursor() + + assert _buffer.text == "hello wrold" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..3a16e9f --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,941 @@ +""" +These are almost end-to-end tests. They create a Prompt, feed it with some +input and check the result. +""" +from __future__ import annotations + +from functools import partial + +import pytest + +from prompt_toolkit.clipboard import ClipboardData, InMemoryClipboard +from prompt_toolkit.enums import EditingMode +from prompt_toolkit.filters import ViInsertMode +from prompt_toolkit.history import InMemoryHistory +from prompt_toolkit.input.defaults import create_pipe_input +from prompt_toolkit.input.vt100_parser import ANSI_SEQUENCES +from prompt_toolkit.key_binding.bindings.named_commands import prefix_meta +from prompt_toolkit.key_binding.key_bindings import KeyBindings +from prompt_toolkit.output import DummyOutput +from prompt_toolkit.shortcuts import PromptSession + + +def _history(): + h = InMemoryHistory() + h.append_string("line1 first input") + h.append_string("line2 second input") + h.append_string("line3 third input") + return h + + +def _feed_cli_with_input( + text, + editing_mode=EditingMode.EMACS, + clipboard=None, + history=None, + multiline=False, + check_line_ending=True, + key_bindings=None, +): + """ + Create a Prompt, feed it with the given user input and return the CLI + object. + + This returns a (result, Application) tuple. + """ + # If the given text doesn't end with a newline, the interface won't finish. + if check_line_ending: + assert text.endswith("\r") + + with create_pipe_input() as inp: + inp.send_text(text) + session = PromptSession( + input=inp, + output=DummyOutput(), + editing_mode=editing_mode, + history=history, + multiline=multiline, + clipboard=clipboard, + key_bindings=key_bindings, + ) + + _ = session.prompt() + return session.default_buffer.document, session.app + + +def test_simple_text_input(): + # Simple text input, followed by enter. + result, cli = _feed_cli_with_input("hello\r") + assert result.text == "hello" + assert cli.current_buffer.text == "hello" + + +def test_emacs_cursor_movements(): + """ + Test cursor movements with Emacs key bindings. + """ + # ControlA (beginning-of-line) + result, cli = _feed_cli_with_input("hello\x01X\r") + assert result.text == "Xhello" + + # ControlE (end-of-line) + result, cli = _feed_cli_with_input("hello\x01X\x05Y\r") + assert result.text == "XhelloY" + + # ControlH or \b + result, cli = _feed_cli_with_input("hello\x08X\r") + assert result.text == "hellX" + + # Delete. (Left, left, delete) + result, cli = _feed_cli_with_input("hello\x1b[D\x1b[D\x1b[3~\r") + assert result.text == "helo" + + # Left. + result, cli = _feed_cli_with_input("hello\x1b[DX\r") + assert result.text == "hellXo" + + # ControlA, right + result, cli = _feed_cli_with_input("hello\x01\x1b[CX\r") + assert result.text == "hXello" + + # ControlB (backward-char) + result, cli = _feed_cli_with_input("hello\x02X\r") + assert result.text == "hellXo" + + # ControlF (forward-char) + result, cli = _feed_cli_with_input("hello\x01\x06X\r") + assert result.text == "hXello" + + # ControlD: delete after cursor. + result, cli = _feed_cli_with_input("hello\x01\x04\r") + assert result.text == "ello" + + # ControlD at the end of the input ssshould not do anything. + result, cli = _feed_cli_with_input("hello\x04\r") + assert result.text == "hello" + + # Left, Left, ControlK (kill-line) + result, cli = _feed_cli_with_input("hello\x1b[D\x1b[D\x0b\r") + assert result.text == "hel" + + # Left, Left Esc- ControlK (kill-line, but negative) + result, cli = _feed_cli_with_input("hello\x1b[D\x1b[D\x1b-\x0b\r") + assert result.text == "lo" + + # ControlL: should not influence the result. + result, cli = _feed_cli_with_input("hello\x0c\r") + assert result.text == "hello" + + # ControlRight (forward-word) + result, cli = _feed_cli_with_input("hello world\x01X\x1b[1;5CY\r") + assert result.text == "XhelloY world" + + # ContrlolLeft (backward-word) + result, cli = _feed_cli_with_input("hello world\x1b[1;5DY\r") + assert result.text == "hello Yworld" + + # <esc>-f with argument. (forward-word) + result, cli = _feed_cli_with_input("hello world abc def\x01\x1b3\x1bfX\r") + assert result.text == "hello world abcX def" + + # <esc>-f with negative argument. (forward-word) + result, cli = _feed_cli_with_input("hello world abc def\x1b-\x1b3\x1bfX\r") + assert result.text == "hello Xworld abc def" + + # <esc>-b with argument. (backward-word) + result, cli = _feed_cli_with_input("hello world abc def\x1b3\x1bbX\r") + assert result.text == "hello Xworld abc def" + + # <esc>-b with negative argument. (backward-word) + result, cli = _feed_cli_with_input("hello world abc def\x01\x1b-\x1b3\x1bbX\r") + assert result.text == "hello world abc Xdef" + + # ControlW (kill-word / unix-word-rubout) + result, cli = _feed_cli_with_input("hello world\x17\r") + assert result.text == "hello " + assert cli.clipboard.get_data().text == "world" + + result, cli = _feed_cli_with_input("test hello world\x1b2\x17\r") + assert result.text == "test " + + # Escape Backspace (unix-word-rubout) + result, cli = _feed_cli_with_input("hello world\x1b\x7f\r") + assert result.text == "hello " + assert cli.clipboard.get_data().text == "world" + + result, cli = _feed_cli_with_input("hello world\x1b\x08\r") + assert result.text == "hello " + assert cli.clipboard.get_data().text == "world" + + # Backspace (backward-delete-char) + result, cli = _feed_cli_with_input("hello world\x7f\r") + assert result.text == "hello worl" + assert result.cursor_position == len("hello worl") + + result, cli = _feed_cli_with_input("hello world\x08\r") + assert result.text == "hello worl" + assert result.cursor_position == len("hello worl") + + # Delete (delete-char) + result, cli = _feed_cli_with_input("hello world\x01\x1b[3~\r") + assert result.text == "ello world" + assert result.cursor_position == 0 + + # Escape-\\ (delete-horizontal-space) + result, cli = _feed_cli_with_input("hello world\x1b8\x02\x1b\\\r") + assert result.text == "helloworld" + assert result.cursor_position == len("hello") + + +def test_emacs_kill_multiple_words_and_paste(): + # Using control-w twice should place both words on the clipboard. + result, cli = _feed_cli_with_input( + "hello world test\x17\x17--\x19\x19\r" # Twice c-w. Twice c-y. + ) + assert result.text == "hello --world testworld test" + assert cli.clipboard.get_data().text == "world test" + + # Using alt-d twice should place both words on the clipboard. + result, cli = _feed_cli_with_input( + "hello world test" + "\x1bb\x1bb" # Twice left. + "\x1bd\x1bd" # Twice kill-word. + "abc" + "\x19" # Paste. + "\r" + ) + assert result.text == "hello abcworld test" + assert cli.clipboard.get_data().text == "world test" + + +def test_interrupts(): + # ControlC: raise KeyboardInterrupt. + with pytest.raises(KeyboardInterrupt): + result, cli = _feed_cli_with_input("hello\x03\r") + + with pytest.raises(KeyboardInterrupt): + result, cli = _feed_cli_with_input("hello\x03\r") + + # ControlD without any input: raises EOFError. + with pytest.raises(EOFError): + result, cli = _feed_cli_with_input("\x04\r") + + +def test_emacs_yank(): + # ControlY (yank) + c = InMemoryClipboard(ClipboardData("XYZ")) + result, cli = _feed_cli_with_input("hello\x02\x19\r", clipboard=c) + assert result.text == "hellXYZo" + assert result.cursor_position == len("hellXYZ") + + +def test_quoted_insert(): + # ControlQ - ControlB (quoted-insert) + result, cli = _feed_cli_with_input("hello\x11\x02\r") + assert result.text == "hello\x02" + + +def test_transformations(): + # Meta-c (capitalize-word) + result, cli = _feed_cli_with_input("hello world\01\x1bc\r") + assert result.text == "Hello world" + assert result.cursor_position == len("Hello") + + # Meta-u (uppercase-word) + result, cli = _feed_cli_with_input("hello world\01\x1bu\r") + assert result.text == "HELLO world" + assert result.cursor_position == len("Hello") + + # Meta-u (downcase-word) + result, cli = _feed_cli_with_input("HELLO WORLD\01\x1bl\r") + assert result.text == "hello WORLD" + assert result.cursor_position == len("Hello") + + # ControlT (transpose-chars) + result, cli = _feed_cli_with_input("hello\x14\r") + assert result.text == "helol" + assert result.cursor_position == len("hello") + + # Left, Left, Control-T (transpose-chars) + result, cli = _feed_cli_with_input("abcde\x1b[D\x1b[D\x14\r") + assert result.text == "abdce" + assert result.cursor_position == len("abcd") + + +def test_emacs_other_bindings(): + # Transpose characters. + result, cli = _feed_cli_with_input("abcde\x14X\r") # Ctrl-T + assert result.text == "abcedX" + + # Left, Left, Transpose. (This is slightly different.) + result, cli = _feed_cli_with_input("abcde\x1b[D\x1b[D\x14X\r") + assert result.text == "abdcXe" + + # Clear before cursor. + result, cli = _feed_cli_with_input("hello\x1b[D\x1b[D\x15X\r") + assert result.text == "Xlo" + + # unix-word-rubout: delete word before the cursor. + # (ControlW). + result, cli = _feed_cli_with_input("hello world test\x17X\r") + assert result.text == "hello world X" + + result, cli = _feed_cli_with_input("hello world /some/very/long/path\x17X\r") + assert result.text == "hello world X" + + # (with argument.) + result, cli = _feed_cli_with_input("hello world test\x1b2\x17X\r") + assert result.text == "hello X" + + result, cli = _feed_cli_with_input("hello world /some/very/long/path\x1b2\x17X\r") + assert result.text == "hello X" + + # backward-kill-word: delete word before the cursor. + # (Esc-ControlH). + result, cli = _feed_cli_with_input("hello world /some/very/long/path\x1b\x08X\r") + assert result.text == "hello world /some/very/long/X" + + # (with arguments.) + result, cli = _feed_cli_with_input( + "hello world /some/very/long/path\x1b3\x1b\x08X\r" + ) + assert result.text == "hello world /some/very/X" + + +def test_controlx_controlx(): + # At the end: go to the start of the line. + result, cli = _feed_cli_with_input("hello world\x18\x18X\r") + assert result.text == "Xhello world" + assert result.cursor_position == 1 + + # At the start: go to the end of the line. + result, cli = _feed_cli_with_input("hello world\x01\x18\x18X\r") + assert result.text == "hello worldX" + + # Left, Left Control-X Control-X: go to the end of the line. + result, cli = _feed_cli_with_input("hello world\x1b[D\x1b[D\x18\x18X\r") + assert result.text == "hello worldX" + + +def test_emacs_history_bindings(): + # Adding a new item to the history. + history = _history() + result, cli = _feed_cli_with_input("new input\r", history=history) + assert result.text == "new input" + history.get_strings()[-1] == "new input" + + # Go up in history, and accept the last item. + result, cli = _feed_cli_with_input("hello\x1b[A\r", history=history) + assert result.text == "new input" + + # Esc< (beginning-of-history) + result, cli = _feed_cli_with_input("hello\x1b<\r", history=history) + assert result.text == "line1 first input" + + # Esc> (end-of-history) + result, cli = _feed_cli_with_input( + "another item\x1b[A\x1b[a\x1b>\r", history=history + ) + assert result.text == "another item" + + # ControlUp (previous-history) + result, cli = _feed_cli_with_input("\x1b[1;5A\r", history=history) + assert result.text == "another item" + + # Esc< ControlDown (beginning-of-history, next-history) + result, cli = _feed_cli_with_input("\x1b<\x1b[1;5B\r", history=history) + assert result.text == "line2 second input" + + +def test_emacs_reverse_search(): + history = _history() + + # ControlR (reverse-search-history) + result, cli = _feed_cli_with_input("\x12input\r\r", history=history) + assert result.text == "line3 third input" + + # Hitting ControlR twice. + result, cli = _feed_cli_with_input("\x12input\x12\r\r", history=history) + assert result.text == "line2 second input" + + +def test_emacs_arguments(): + """ + Test various combinations of arguments in Emacs mode. + """ + # esc 4 + result, cli = _feed_cli_with_input("\x1b4x\r") + assert result.text == "xxxx" + + # esc 4 4 + result, cli = _feed_cli_with_input("\x1b44x\r") + assert result.text == "x" * 44 + + # esc 4 esc 4 + result, cli = _feed_cli_with_input("\x1b4\x1b4x\r") + assert result.text == "x" * 44 + + # esc - right (-1 position to the right, equals 1 to the left.) + result, cli = _feed_cli_with_input("aaaa\x1b-\x1b[Cbbbb\r") + assert result.text == "aaabbbba" + + # esc - 3 right + result, cli = _feed_cli_with_input("aaaa\x1b-3\x1b[Cbbbb\r") + assert result.text == "abbbbaaa" + + # esc - - - 3 right + result, cli = _feed_cli_with_input("aaaa\x1b---3\x1b[Cbbbb\r") + assert result.text == "abbbbaaa" + + +def test_emacs_arguments_for_all_commands(): + """ + Test all Emacs commands with Meta-[0-9] arguments (both positive and + negative). No one should crash. + """ + for key in ANSI_SEQUENCES: + # Ignore BracketedPaste. This would hang forever, because it waits for + # the end sequence. + if key != "\x1b[200~": + try: + # Note: we add an 'X' after the key, because Ctrl-Q (quoted-insert) + # expects something to follow. We add an additional \r, because + # Ctrl-R and Ctrl-S (reverse-search) expect that. + result, cli = _feed_cli_with_input("hello\x1b4" + key + "X\r\r") + + result, cli = _feed_cli_with_input("hello\x1b-" + key + "X\r\r") + except KeyboardInterrupt: + # This exception should only be raised for Ctrl-C + assert key == "\x03" + + +def test_emacs_kill_ring(): + operations = ( + # abc ControlA ControlK + "abc\x01\x0b" + # def ControlA ControlK + "def\x01\x0b" + # ghi ControlA ControlK + "ghi\x01\x0b" + # ControlY (yank) + "\x19" + ) + + result, cli = _feed_cli_with_input(operations + "\r") + assert result.text == "ghi" + + result, cli = _feed_cli_with_input(operations + "\x1by\r") + assert result.text == "def" + + result, cli = _feed_cli_with_input(operations + "\x1by\x1by\r") + assert result.text == "abc" + + result, cli = _feed_cli_with_input(operations + "\x1by\x1by\x1by\r") + assert result.text == "ghi" + + +def test_emacs_selection(): + # Copy/paste empty selection should not do anything. + operations = ( + "hello" + # Twice left. + "\x1b[D\x1b[D" + # Control-Space + "\x00" + # ControlW (cut) + "\x17" + # ControlY twice. (paste twice) + "\x19\x19\r" + ) + + result, cli = _feed_cli_with_input(operations) + assert result.text == "hello" + + # Copy/paste one character. + operations = ( + "hello" + # Twice left. + "\x1b[D\x1b[D" + # Control-Space + "\x00" + # Right. + "\x1b[C" + # ControlW (cut) + "\x17" + # ControlA (Home). + "\x01" + # ControlY (paste) + "\x19\r" + ) + + result, cli = _feed_cli_with_input(operations) + assert result.text == "lhelo" + + +def test_emacs_insert_comment(): + # Test insert-comment (M-#) binding. + result, cli = _feed_cli_with_input("hello\x1b#", check_line_ending=False) + assert result.text == "#hello" + + result, cli = _feed_cli_with_input( + "hello\rworld\x1b#", check_line_ending=False, multiline=True + ) + assert result.text == "#hello\n#world" + + +def test_emacs_record_macro(): + operations = ( + " " + "\x18(" # Start recording macro. C-X( + "hello" + "\x18)" # Stop recording macro. + " " + "\x18e" # Execute macro. + "\x18e" # Execute macro. + "\r" + ) + + result, cli = _feed_cli_with_input(operations) + assert result.text == " hello hellohello" + + +def test_emacs_nested_macro(): + "Test calling the macro within a macro." + # Calling a macro within a macro should take the previous recording (if one + # exists), not the one that is in progress. + operations = ( + "\x18(" # Start recording macro. C-X( + "hello" + "\x18e" # Execute macro. + "\x18)" # Stop recording macro. + "\x18e" # Execute macro. + "\r" + ) + + result, cli = _feed_cli_with_input(operations) + assert result.text == "hellohello" + + operations = ( + "\x18(" # Start recording macro. C-X( + "hello" + "\x18)" # Stop recording macro. + "\x18(" # Start recording macro. C-X( + "\x18e" # Execute macro. + "world" + "\x18)" # Stop recording macro. + "\x01\x0b" # Delete all (c-a c-k). + "\x18e" # Execute macro. + "\r" + ) + + result, cli = _feed_cli_with_input(operations) + assert result.text == "helloworld" + + +def test_prefix_meta(): + # Test the prefix-meta command. + b = KeyBindings() + b.add("j", "j", filter=ViInsertMode())(prefix_meta) + + result, cli = _feed_cli_with_input( + "hellojjIX\r", key_bindings=b, editing_mode=EditingMode.VI + ) + assert result.text == "Xhello" + + +def test_bracketed_paste(): + result, cli = _feed_cli_with_input("\x1b[200~hello world\x1b[201~\r") + assert result.text == "hello world" + + result, cli = _feed_cli_with_input("\x1b[200~hello\rworld\x1b[201~\x1b\r") + assert result.text == "hello\nworld" + + # With \r\n endings. + result, cli = _feed_cli_with_input("\x1b[200~hello\r\nworld\x1b[201~\x1b\r") + assert result.text == "hello\nworld" + + # With \n endings. + result, cli = _feed_cli_with_input("\x1b[200~hello\nworld\x1b[201~\x1b\r") + assert result.text == "hello\nworld" + + +def test_vi_cursor_movements(): + """ + Test cursor movements with Vi key bindings. + """ + feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI) + + result, cli = feed("\x1b\r") + assert result.text == "" + assert cli.editing_mode == EditingMode.VI + + # Esc h a X + result, cli = feed("hello\x1bhaX\r") + assert result.text == "hellXo" + + # Esc I X + result, cli = feed("hello\x1bIX\r") + assert result.text == "Xhello" + + # Esc I X + result, cli = feed("hello\x1bIX\r") + assert result.text == "Xhello" + + # Esc 2hiX + result, cli = feed("hello\x1b2hiX\r") + assert result.text == "heXllo" + + # Esc 2h2liX + result, cli = feed("hello\x1b2h2liX\r") + assert result.text == "hellXo" + + # Esc \b\b + result, cli = feed("hello\b\b\r") + assert result.text == "hel" + + # Esc \b\b + result, cli = feed("hello\b\b\r") + assert result.text == "hel" + + # Esc 2h D + result, cli = feed("hello\x1b2hD\r") + assert result.text == "he" + + # Esc 2h rX \r + result, cli = feed("hello\x1b2hrX\r") + assert result.text == "heXlo" + + +def test_vi_operators(): + feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI) + + # Esc g~0 + result, cli = feed("hello\x1bg~0\r") + assert result.text == "HELLo" + + # Esc gU0 + result, cli = feed("hello\x1bgU0\r") + assert result.text == "HELLo" + + # Esc d0 + result, cli = feed("hello\x1bd0\r") + assert result.text == "o" + + +def test_vi_text_objects(): + feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI) + + # Esc gUgg + result, cli = feed("hello\x1bgUgg\r") + assert result.text == "HELLO" + + # Esc gUU + result, cli = feed("hello\x1bgUU\r") + assert result.text == "HELLO" + + # Esc di( + result, cli = feed("before(inside)after\x1b8hdi(\r") + assert result.text == "before()after" + + # Esc di[ + result, cli = feed("before[inside]after\x1b8hdi[\r") + assert result.text == "before[]after" + + # Esc da( + result, cli = feed("before(inside)after\x1b8hda(\r") + assert result.text == "beforeafter" + + +def test_vi_digraphs(): + feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI) + + # C-K o/ + result, cli = feed("hello\x0bo/\r") + assert result.text == "helloø" + + # C-K /o (reversed input.) + result, cli = feed("hello\x0b/o\r") + assert result.text == "helloø" + + # C-K e: + result, cli = feed("hello\x0be:\r") + assert result.text == "helloë" + + # C-K xxy (Unknown digraph.) + result, cli = feed("hello\x0bxxy\r") + assert result.text == "helloy" + + +def test_vi_block_editing(): + "Test Vi Control-V style block insertion." + feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI, multiline=True) + + operations = ( + # Six lines of text. + "-line1\r-line2\r-line3\r-line4\r-line5\r-line6" + # Go to the second character of the second line. + "\x1bkkkkkkkj0l" + # Enter Visual block mode. + "\x16" + # Go down two more lines. + "jj" + # Go 3 characters to the right. + "lll" + # Go to insert mode. + "insert" # (Will be replaced.) + # Insert stars. + "***" + # Escape again. + "\x1b\r" + ) + + # Control-I + result, cli = feed(operations.replace("insert", "I")) + + assert result.text == "-line1\n-***line2\n-***line3\n-***line4\n-line5\n-line6" + + # Control-A + result, cli = feed(operations.replace("insert", "A")) + + assert result.text == "-line1\n-line***2\n-line***3\n-line***4\n-line5\n-line6" + + +def test_vi_block_editing_empty_lines(): + "Test block editing on empty lines." + feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI, multiline=True) + + operations = ( + # Six empty lines. + "\r\r\r\r\r" + # Go to beginning of the document. + "\x1bgg" + # Enter Visual block mode. + "\x16" + # Go down two more lines. + "jj" + # Go 3 characters to the right. + "lll" + # Go to insert mode. + "insert" # (Will be replaced.) + # Insert stars. + "***" + # Escape again. + "\x1b\r" + ) + + # Control-I + result, cli = feed(operations.replace("insert", "I")) + + assert result.text == "***\n***\n***\n\n\n" + + # Control-A + result, cli = feed(operations.replace("insert", "A")) + + assert result.text == "***\n***\n***\n\n\n" + + +def test_vi_visual_line_copy(): + feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI, multiline=True) + + operations = ( + # Three lines of text. + "-line1\r-line2\r-line3\r-line4\r-line5\r-line6" + # Go to the second character of the second line. + "\x1bkkkkkkkj0l" + # Enter Visual linemode. + "V" + # Go down one line. + "j" + # Go 3 characters to the right (should not do much). + "lll" + # Copy this block. + "y" + # Go down one line. + "j" + # Insert block twice. + "2p" + # Escape again. + "\x1b\r" + ) + + result, cli = feed(operations) + + assert ( + result.text + == "-line1\n-line2\n-line3\n-line4\n-line2\n-line3\n-line2\n-line3\n-line5\n-line6" + ) + + +def test_vi_visual_empty_line(): + """ + Test edge case with an empty line in Visual-line mode. + """ + feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI, multiline=True) + + # 1. Delete first two lines. + operations = ( + # Three lines of text. The middle one is empty. + "hello\r\rworld" + # Go to the start. + "\x1bgg" + # Visual line and move down. + "Vj" + # Delete. + "d\r" + ) + result, cli = feed(operations) + assert result.text == "world" + + # 1. Delete middle line. + operations = ( + # Three lines of text. The middle one is empty. + "hello\r\rworld" + # Go to middle line. + "\x1bggj" + # Delete line + "Vd\r" + ) + + result, cli = feed(operations) + assert result.text == "hello\nworld" + + +def test_vi_character_delete_after_cursor(): + "Test 'x' keypress." + feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI, multiline=True) + + # Delete one character. + result, cli = feed("abcd\x1bHx\r") + assert result.text == "bcd" + + # Delete multiple character.s + result, cli = feed("abcd\x1bH3x\r") + assert result.text == "d" + + # Delete on empty line. + result, cli = feed("\x1bo\x1bo\x1bggx\r") + assert result.text == "\n\n" + + # Delete multiple on empty line. + result, cli = feed("\x1bo\x1bo\x1bgg10x\r") + assert result.text == "\n\n" + + # Delete multiple on empty line. + result, cli = feed("hello\x1bo\x1bo\x1bgg3x\r") + assert result.text == "lo\n\n" + + +def test_vi_character_delete_before_cursor(): + "Test 'X' keypress." + feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI, multiline=True) + + # Delete one character. + result, cli = feed("abcd\x1bX\r") + assert result.text == "abd" + + # Delete multiple character. + result, cli = feed("hello world\x1b3X\r") + assert result.text == "hello wd" + + # Delete multiple character on multiple lines. + result, cli = feed("hello\x1boworld\x1bgg$3X\r") + assert result.text == "ho\nworld" + + result, cli = feed("hello\x1boworld\x1b100X\r") + assert result.text == "hello\nd" + + # Delete on empty line. + result, cli = feed("\x1bo\x1bo\x1b10X\r") + assert result.text == "\n\n" + + +def test_vi_character_paste(): + feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI) + + # Test 'p' character paste. + result, cli = feed("abcde\x1bhhxp\r") + assert result.text == "abdce" + assert result.cursor_position == 3 + + # Test 'P' character paste. + result, cli = feed("abcde\x1bhhxP\r") + assert result.text == "abcde" + assert result.cursor_position == 2 + + +def test_vi_temp_navigation_mode(): + """ + Test c-o binding: go for one action into 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. + assert result.text == "axbcde" + assert result.cursor_position == 2 + + result, cli = feed("abcde" "\x0f" "b" "x\r") # c-o # One word backwards. + assert result.text == "xabcde" + assert result.cursor_position == 1 + + # In replace mode + result, cli = feed( + "abcdef" + "\x1b" # Navigation mode. + "0l" # Start of line, one character to the right. + "R" # Replace mode + "78" + "\x0f" # c-o + "l" # One character forwards. + "9\r" + ) + assert result.text == "a78d9f" + assert result.cursor_position == 5 + + +def test_vi_macros(): + feed = partial(_feed_cli_with_input, editing_mode=EditingMode.VI) + + # Record and execute macro. + result, cli = feed("\x1bqcahello\x1bq@c\r") + assert result.text == "hellohello" + assert result.cursor_position == 9 + + # Running unknown macro. + result, cli = feed("\x1b@d\r") + assert result.text == "" + assert result.cursor_position == 0 + + # When a macro is called within a macro. + # It shouldn't result in eternal recursion. + result, cli = feed("\x1bqxahello\x1b@xq@x\r") + assert result.text == "hellohello" + assert result.cursor_position == 9 + + # Nested macros. + result, cli = feed( + # Define macro 'x'. + "\x1bqxahello\x1bq" + # Define macro 'y' which calls 'x'. + "qya\x1b@xaworld\x1bq" + # Delete line. + "2dd" + # Execute 'y' + "@y\r" + ) + + assert result.text == "helloworld" + + +def test_accept_default(): + """ + Test `prompt(accept_default=True)`. + """ + with create_pipe_input() as inp: + session = PromptSession(input=inp, output=DummyOutput()) + result = session.prompt(default="hello", accept_default=True) + assert result == "hello" + + # Test calling prompt() for a second time. (We had an issue where the + # prompt reset between calls happened at the wrong time, breaking this.) + result = session.prompt(default="world", accept_default=True) + assert result == "world" diff --git a/tests/test_completion.py b/tests/test_completion.py new file mode 100644 index 0000000..8b3541a --- /dev/null +++ b/tests/test_completion.py @@ -0,0 +1,469 @@ +from __future__ import annotations + +import os +import re +import shutil +import tempfile +from contextlib import contextmanager + +from prompt_toolkit.completion import ( + CompleteEvent, + FuzzyWordCompleter, + NestedCompleter, + PathCompleter, + WordCompleter, + merge_completers, +) +from prompt_toolkit.document import Document + + +@contextmanager +def chdir(directory): + """Context manager for current working directory temporary change.""" + orig_dir = os.getcwd() + os.chdir(directory) + + try: + yield + finally: + os.chdir(orig_dir) + + +def write_test_files(test_dir, names=None): + """Write test files in test_dir using the names list.""" + names = names or range(10) + for i in names: + with open(os.path.join(test_dir, str(i)), "wb") as out: + out.write(b"") + + +def test_pathcompleter_completes_in_current_directory(): + completer = PathCompleter() + doc_text = "" + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + assert len(completions) > 0 + + +def test_pathcompleter_completes_files_in_current_directory(): + # setup: create a test dir with 10 files + test_dir = tempfile.mkdtemp() + write_test_files(test_dir) + + expected = sorted(str(i) for i in range(10)) + + if not test_dir.endswith(os.path.sep): + test_dir += os.path.sep + + with chdir(test_dir): + completer = PathCompleter() + # this should complete on the cwd + doc_text = "" + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + result = sorted(c.text for c in completions) + assert expected == result + + # cleanup + shutil.rmtree(test_dir) + + +def test_pathcompleter_completes_files_in_absolute_directory(): + # setup: create a test dir with 10 files + test_dir = tempfile.mkdtemp() + write_test_files(test_dir) + + expected = sorted(str(i) for i in range(10)) + + test_dir = os.path.abspath(test_dir) + if not test_dir.endswith(os.path.sep): + test_dir += os.path.sep + + completer = PathCompleter() + # force unicode + doc_text = str(test_dir) + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + result = sorted(c.text for c in completions) + assert expected == result + + # cleanup + shutil.rmtree(test_dir) + + +def test_pathcompleter_completes_directories_with_only_directories(): + # setup: create a test dir with 10 files + test_dir = tempfile.mkdtemp() + write_test_files(test_dir) + + # create a sub directory there + os.mkdir(os.path.join(test_dir, "subdir")) + + if not test_dir.endswith(os.path.sep): + test_dir += os.path.sep + + with chdir(test_dir): + completer = PathCompleter(only_directories=True) + doc_text = "" + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + result = [c.text for c in completions] + assert ["subdir"] == result + + # check that there is no completion when passing a file + with chdir(test_dir): + completer = PathCompleter(only_directories=True) + doc_text = "1" + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + assert [] == completions + + # cleanup + shutil.rmtree(test_dir) + + +def test_pathcompleter_respects_completions_under_min_input_len(): + # setup: create a test dir with 10 files + test_dir = tempfile.mkdtemp() + write_test_files(test_dir) + + # min len:1 and no text + with chdir(test_dir): + completer = PathCompleter(min_input_len=1) + doc_text = "" + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + assert [] == completions + + # min len:1 and text of len 1 + with chdir(test_dir): + completer = PathCompleter(min_input_len=1) + doc_text = "1" + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + result = [c.text for c in completions] + assert [""] == result + + # min len:0 and text of len 2 + with chdir(test_dir): + completer = PathCompleter(min_input_len=0) + doc_text = "1" + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + result = [c.text for c in completions] + assert [""] == result + + # create 10 files with a 2 char long name + for i in range(10): + with open(os.path.join(test_dir, str(i) * 2), "wb") as out: + out.write(b"") + + # min len:1 and text of len 1 + with chdir(test_dir): + completer = PathCompleter(min_input_len=1) + doc_text = "2" + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + result = sorted(c.text for c in completions) + assert ["", "2"] == result + + # min len:2 and text of len 1 + with chdir(test_dir): + completer = PathCompleter(min_input_len=2) + doc_text = "2" + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + assert [] == completions + + # cleanup + shutil.rmtree(test_dir) + + +def test_pathcompleter_does_not_expanduser_by_default(): + completer = PathCompleter() + doc_text = "~" + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + assert [] == completions + + +def test_pathcompleter_can_expanduser(): + completer = PathCompleter(expanduser=True) + doc_text = "~" + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + assert len(completions) > 0 + + +def test_pathcompleter_can_apply_file_filter(): + # setup: create a test dir with 10 files + test_dir = tempfile.mkdtemp() + write_test_files(test_dir) + + # add a .csv file + with open(os.path.join(test_dir, "my.csv"), "wb") as out: + out.write(b"") + + file_filter = lambda f: f and f.endswith(".csv") + + with chdir(test_dir): + completer = PathCompleter(file_filter=file_filter) + doc_text = "" + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + result = [c.text for c in completions] + assert ["my.csv"] == result + + # cleanup + shutil.rmtree(test_dir) + + +def test_pathcompleter_get_paths_constrains_path(): + # setup: create a test dir with 10 files + test_dir = tempfile.mkdtemp() + write_test_files(test_dir) + + # add a subdir with 10 other files with different names + subdir = os.path.join(test_dir, "subdir") + os.mkdir(subdir) + write_test_files(subdir, "abcdefghij") + + get_paths = lambda: ["subdir"] + + with chdir(test_dir): + completer = PathCompleter(get_paths=get_paths) + doc_text = "" + doc = Document(doc_text, len(doc_text)) + event = CompleteEvent() + completions = list(completer.get_completions(doc, event)) + result = [c.text for c in completions] + expected = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"] + assert expected == result + + # cleanup + shutil.rmtree(test_dir) + + +def test_word_completer_static_word_list(): + completer = WordCompleter(["abc", "def", "aaa"]) + + # Static list on empty input. + completions = completer.get_completions(Document(""), CompleteEvent()) + assert [c.text for c in completions] == ["abc", "def", "aaa"] + + # Static list on non-empty input. + completions = completer.get_completions(Document("a"), CompleteEvent()) + assert [c.text for c in completions] == ["abc", "aaa"] + + completions = completer.get_completions(Document("A"), CompleteEvent()) + assert [c.text for c in completions] == [] + + # Multiple words ending with space. (Accept all options) + completions = completer.get_completions(Document("test "), CompleteEvent()) + assert [c.text for c in completions] == ["abc", "def", "aaa"] + + # Multiple words. (Check last only.) + completions = completer.get_completions(Document("test a"), CompleteEvent()) + assert [c.text for c in completions] == ["abc", "aaa"] + + +def test_word_completer_ignore_case(): + completer = WordCompleter(["abc", "def", "aaa"], ignore_case=True) + completions = completer.get_completions(Document("a"), CompleteEvent()) + assert [c.text for c in completions] == ["abc", "aaa"] + + completions = completer.get_completions(Document("A"), CompleteEvent()) + assert [c.text for c in completions] == ["abc", "aaa"] + + +def test_word_completer_match_middle(): + completer = WordCompleter(["abc", "def", "abca"], match_middle=True) + completions = completer.get_completions(Document("bc"), CompleteEvent()) + assert [c.text for c in completions] == ["abc", "abca"] + + +def test_word_completer_sentence(): + # With sentence=True + completer = WordCompleter( + ["hello world", "www", "hello www", "hello there"], sentence=True + ) + completions = completer.get_completions(Document("hello w"), CompleteEvent()) + assert [c.text for c in completions] == ["hello world", "hello www"] + + # With sentence=False + completer = WordCompleter( + ["hello world", "www", "hello www", "hello there"], sentence=False + ) + completions = completer.get_completions(Document("hello w"), CompleteEvent()) + assert [c.text for c in completions] == ["www"] + + +def test_word_completer_dynamic_word_list(): + called = [0] + + def get_words(): + called[0] += 1 + return ["abc", "def", "aaa"] + + completer = WordCompleter(get_words) + + # Dynamic list on empty input. + completions = completer.get_completions(Document(""), CompleteEvent()) + assert [c.text for c in completions] == ["abc", "def", "aaa"] + assert called[0] == 1 + + # Static list on non-empty input. + completions = completer.get_completions(Document("a"), CompleteEvent()) + assert [c.text for c in completions] == ["abc", "aaa"] + assert called[0] == 2 + + +def test_word_completer_pattern(): + # With a pattern which support '.' + completer = WordCompleter( + ["abc", "a.b.c", "a.b", "xyz"], + pattern=re.compile(r"^([a-zA-Z0-9_.]+|[^a-zA-Z0-9_.\s]+)"), + ) + completions = completer.get_completions(Document("a."), CompleteEvent()) + assert [c.text for c in completions] == ["a.b.c", "a.b"] + + # Without pattern + completer = WordCompleter(["abc", "a.b.c", "a.b", "xyz"]) + completions = completer.get_completions(Document("a."), CompleteEvent()) + assert [c.text for c in completions] == [] + + +def test_fuzzy_completer(): + collection = [ + "migrations.py", + "django_migrations.py", + "django_admin_log.py", + "api_user.doc", + "user_group.doc", + "users.txt", + "accounts.txt", + "123.py", + "test123test.py", + ] + completer = FuzzyWordCompleter(collection) + completions = completer.get_completions(Document("txt"), CompleteEvent()) + assert [c.text for c in completions] == ["users.txt", "accounts.txt"] + + completions = completer.get_completions(Document("djmi"), CompleteEvent()) + assert [c.text for c in completions] == [ + "django_migrations.py", + "django_admin_log.py", + ] + + completions = completer.get_completions(Document("mi"), CompleteEvent()) + assert [c.text for c in completions] == [ + "migrations.py", + "django_migrations.py", + "django_admin_log.py", + ] + + completions = completer.get_completions(Document("user"), CompleteEvent()) + assert [c.text for c in completions] == [ + "user_group.doc", + "users.txt", + "api_user.doc", + ] + + completions = completer.get_completions(Document("123"), CompleteEvent()) + assert [c.text for c in completions] == ["123.py", "test123test.py"] + + completions = completer.get_completions(Document("miGr"), CompleteEvent()) + assert [c.text for c in completions] == [ + "migrations.py", + "django_migrations.py", + ] + + # Multiple words ending with space. (Accept all options) + completions = completer.get_completions(Document("test "), CompleteEvent()) + assert [c.text for c in completions] == collection + + # Multiple words. (Check last only.) + completions = completer.get_completions(Document("test txt"), CompleteEvent()) + assert [c.text for c in completions] == ["users.txt", "accounts.txt"] + + +def test_nested_completer(): + completer = NestedCompleter.from_nested_dict( + { + "show": { + "version": None, + "clock": None, + "interfaces": None, + "ip": {"interface": {"brief"}}, + }, + "exit": None, + } + ) + + # Empty input. + completions = completer.get_completions(Document(""), CompleteEvent()) + assert {c.text for c in completions} == {"show", "exit"} + + # One character. + completions = completer.get_completions(Document("s"), CompleteEvent()) + assert {c.text for c in completions} == {"show"} + + # One word. + completions = completer.get_completions(Document("show"), CompleteEvent()) + assert {c.text for c in completions} == {"show"} + + # One word + space. + completions = completer.get_completions(Document("show "), CompleteEvent()) + assert {c.text for c in completions} == {"version", "clock", "interfaces", "ip"} + + # One word + space + one character. + completions = completer.get_completions(Document("show i"), CompleteEvent()) + assert {c.text for c in completions} == {"ip", "interfaces"} + + # One space + one word + space + one character. + completions = completer.get_completions(Document(" show i"), CompleteEvent()) + assert {c.text for c in completions} == {"ip", "interfaces"} + + # Test nested set. + completions = completer.get_completions( + Document("show ip interface br"), CompleteEvent() + ) + assert {c.text for c in completions} == {"brief"} + + +def test_deduplicate_completer(): + def create_completer(deduplicate: bool): + return merge_completers( + [ + WordCompleter(["hello", "world", "abc", "def"]), + WordCompleter(["xyz", "xyz", "abc", "def"]), + ], + deduplicate=deduplicate, + ) + + completions = list( + create_completer(deduplicate=False).get_completions( + Document(""), CompleteEvent() + ) + ) + assert len(completions) == 8 + + completions = list( + create_completer(deduplicate=True).get_completions( + Document(""), CompleteEvent() + ) + ) + assert len(completions) == 5 diff --git a/tests/test_document.py b/tests/test_document.py new file mode 100644 index 0000000..d052d53 --- /dev/null +++ b/tests/test_document.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import pytest + +from prompt_toolkit.document import Document + + +@pytest.fixture +def document(): + return Document( + "line 1\n" + "line 2\n" + "line 3\n" + "line 4\n", len("line 1\n" + "lin") + ) + + +def test_current_char(document): + assert document.current_char == "e" + assert document.char_before_cursor == "n" + + +def test_text_before_cursor(document): + assert document.text_before_cursor == "line 1\nlin" + + +def test_text_after_cursor(document): + assert document.text_after_cursor == "e 2\n" + "line 3\n" + "line 4\n" + + +def test_lines(document): + assert document.lines == ["line 1", "line 2", "line 3", "line 4", ""] + + +def test_line_count(document): + assert document.line_count == 5 + + +def test_current_line_before_cursor(document): + assert document.current_line_before_cursor == "lin" + + +def test_current_line_after_cursor(document): + assert document.current_line_after_cursor == "e 2" + + +def test_current_line(document): + assert document.current_line == "line 2" + + +def test_cursor_position(document): + assert document.cursor_position_row == 1 + assert document.cursor_position_col == 3 + + d = Document("", 0) + assert d.cursor_position_row == 0 + assert d.cursor_position_col == 0 + + +def test_translate_index_to_position(document): + pos = document.translate_index_to_position(len("line 1\nline 2\nlin")) + + assert pos[0] == 2 + assert pos[1] == 3 + + pos = document.translate_index_to_position(0) + assert pos == (0, 0) + + +def test_is_cursor_at_the_end(document): + assert Document("hello", 5).is_cursor_at_the_end + assert not Document("hello", 4).is_cursor_at_the_end diff --git a/tests/test_filter.py b/tests/test_filter.py new file mode 100644 index 0000000..f7184c2 --- /dev/null +++ b/tests/test_filter.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import pytest + +from prompt_toolkit.filters import Always, Condition, Filter, Never, to_filter +from prompt_toolkit.filters.base import _AndList, _OrList + + +def test_never(): + assert not Never()() + + +def test_always(): + assert Always()() + + +def test_invert(): + assert not (~Always())() + assert ~Never()() + + c = ~Condition(lambda: False) + assert c() + + +def test_or(): + for a in (True, False): + for b in (True, False): + c1 = Condition(lambda: a) + c2 = Condition(lambda: b) + c3 = c1 | c2 + + assert isinstance(c3, Filter) + assert c3() == a or b + + +def test_and(): + for a in (True, False): + for b in (True, False): + c1 = Condition(lambda: a) + c2 = Condition(lambda: b) + c3 = c1 & c2 + + assert isinstance(c3, Filter) + assert c3() == (a and b) + + +def test_nested_and(): + for a in (True, False): + for b in (True, False): + for c in (True, False): + c1 = Condition(lambda: a) + c2 = Condition(lambda: b) + c3 = Condition(lambda: c) + c4 = (c1 & c2) & c3 + + assert isinstance(c4, Filter) + assert c4() == (a and b and c) + + +def test_nested_or(): + for a in (True, False): + for b in (True, False): + for c in (True, False): + c1 = Condition(lambda: a) + c2 = Condition(lambda: b) + c3 = Condition(lambda: c) + c4 = (c1 | c2) | c3 + + assert isinstance(c4, Filter) + assert c4() == (a or b or c) + + +def test_to_filter(): + f1 = to_filter(True) + f2 = to_filter(False) + f3 = to_filter(Condition(lambda: True)) + f4 = to_filter(Condition(lambda: False)) + + assert isinstance(f1, Filter) + assert isinstance(f2, Filter) + assert isinstance(f3, Filter) + assert isinstance(f4, Filter) + assert f1() + assert not f2() + assert f3() + assert not f4() + + with pytest.raises(TypeError): + to_filter(4) + + +def test_filter_cache_regression_1(): + # See: https://github.com/prompt-toolkit/python-prompt-toolkit/issues/1729 + + cond = Condition(lambda: True) + + # The use of a `WeakValueDictionary` caused this following expression to + # fail. The problem is that the nested `(a & a)` expression gets garbage + # collected between the two statements and is removed from our cache. + x = (cond & cond) & cond + y = (cond & cond) & cond + assert x == y + + +def test_filter_cache_regression_2(): + cond1 = Condition(lambda: True) + cond2 = Condition(lambda: True) + cond3 = Condition(lambda: True) + + x = (cond1 & cond2) & cond3 + y = (cond1 & cond2) & cond3 + assert x == y + + +def test_filter_remove_duplicates(): + cond1 = Condition(lambda: True) + cond2 = Condition(lambda: True) + + # When a condition is appended to itself using an `&` or `|` operator, it + # should not be present twice. Having it twice in the `_AndList` or + # `_OrList` will make them more expensive to evaluate. + + assert isinstance(cond1 & cond1, Condition) + assert isinstance(cond1 & cond1 & cond1, Condition) + assert isinstance(cond1 & cond1 & cond2, _AndList) + assert len((cond1 & cond1 & cond2).filters) == 2 + + assert isinstance(cond1 | cond1, Condition) + assert isinstance(cond1 | cond1 | cond1, Condition) + assert isinstance(cond1 | cond1 | cond2, _OrList) + assert len((cond1 | cond1 | cond2).filters) == 2 diff --git a/tests/test_formatted_text.py b/tests/test_formatted_text.py new file mode 100644 index 0000000..843aac1 --- /dev/null +++ b/tests/test_formatted_text.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +from prompt_toolkit.formatted_text import ( + ANSI, + HTML, + FormattedText, + PygmentsTokens, + Template, + merge_formatted_text, + to_formatted_text, +) +from prompt_toolkit.formatted_text.utils import split_lines + + +def test_basic_html(): + html = HTML("<i>hello</i>") + assert to_formatted_text(html) == [("class:i", "hello")] + + html = HTML("<i><b>hello</b></i>") + assert to_formatted_text(html) == [("class:i,b", "hello")] + + html = HTML("<i><b>hello</b>world<strong>test</strong></i>after") + assert to_formatted_text(html) == [ + ("class:i,b", "hello"), + ("class:i", "world"), + ("class:i,strong", "test"), + ("", "after"), + ] + + # It's important that `to_formatted_text` returns a `FormattedText` + # instance. Otherwise, `print_formatted_text` won't recognize it and will + # print a list literal instead. + assert isinstance(to_formatted_text(html), FormattedText) + + +def test_html_with_fg_bg(): + html = HTML('<style bg="ansired">hello</style>') + assert to_formatted_text(html) == [ + ("bg:ansired", "hello"), + ] + + html = HTML('<style bg="ansired" fg="#ff0000">hello</style>') + assert to_formatted_text(html) == [ + ("fg:#ff0000 bg:ansired", "hello"), + ] + + html = HTML( + '<style bg="ansired" fg="#ff0000">hello <world fg="ansiblue">world</world></style>' + ) + assert to_formatted_text(html) == [ + ("fg:#ff0000 bg:ansired", "hello "), + ("class:world fg:ansiblue bg:ansired", "world"), + ] + + +def test_ansi_formatting(): + value = ANSI("\x1b[32mHe\x1b[45mllo") + + assert to_formatted_text(value) == [ + ("ansigreen", "H"), + ("ansigreen", "e"), + ("ansigreen bg:ansimagenta", "l"), + ("ansigreen bg:ansimagenta", "l"), + ("ansigreen bg:ansimagenta", "o"), + ] + + # Bold and italic. + value = ANSI("\x1b[1mhe\x1b[0mllo") + + assert to_formatted_text(value) == [ + ("bold", "h"), + ("bold", "e"), + ("", "l"), + ("", "l"), + ("", "o"), + ] + + # Zero width escapes. + value = ANSI("ab\001cd\002ef") + + assert to_formatted_text(value) == [ + ("", "a"), + ("", "b"), + ("[ZeroWidthEscape]", "cd"), + ("", "e"), + ("", "f"), + ] + + assert isinstance(to_formatted_text(value), FormattedText) + + +def test_ansi_256_color(): + assert to_formatted_text(ANSI("\x1b[38;5;124mtest")) == [ + ("#af0000", "t"), + ("#af0000", "e"), + ("#af0000", "s"), + ("#af0000", "t"), + ] + + +def test_ansi_true_color(): + assert to_formatted_text(ANSI("\033[38;2;144;238;144m$\033[0;39;49m ")) == [ + ("#90ee90", "$"), + ("ansidefault bg:ansidefault", " "), + ] + + +def test_ansi_interpolation(): + # %-style interpolation. + value = ANSI("\x1b[1m%s\x1b[0m") % "hello\x1b" + assert to_formatted_text(value) == [ + ("bold", "h"), + ("bold", "e"), + ("bold", "l"), + ("bold", "l"), + ("bold", "o"), + ("bold", "?"), + ] + + value = ANSI("\x1b[1m%s\x1b[0m") % ("\x1bhello",) + assert to_formatted_text(value) == [ + ("bold", "?"), + ("bold", "h"), + ("bold", "e"), + ("bold", "l"), + ("bold", "l"), + ("bold", "o"), + ] + + value = ANSI("\x1b[32m%s\x1b[45m%s") % ("He", "\x1bllo") + assert to_formatted_text(value) == [ + ("ansigreen", "H"), + ("ansigreen", "e"), + ("ansigreen bg:ansimagenta", "?"), + ("ansigreen bg:ansimagenta", "l"), + ("ansigreen bg:ansimagenta", "l"), + ("ansigreen bg:ansimagenta", "o"), + ] + + # Format function. + value = ANSI("\x1b[32m{0}\x1b[45m{1}").format("He\x1b", "llo") + assert to_formatted_text(value) == [ + ("ansigreen", "H"), + ("ansigreen", "e"), + ("ansigreen", "?"), + ("ansigreen bg:ansimagenta", "l"), + ("ansigreen bg:ansimagenta", "l"), + ("ansigreen bg:ansimagenta", "o"), + ] + + value = ANSI("\x1b[32m{a}\x1b[45m{b}").format(a="\x1bHe", b="llo") + assert to_formatted_text(value) == [ + ("ansigreen", "?"), + ("ansigreen", "H"), + ("ansigreen", "e"), + ("ansigreen bg:ansimagenta", "l"), + ("ansigreen bg:ansimagenta", "l"), + ("ansigreen bg:ansimagenta", "o"), + ] + + value = ANSI("\x1b[32m{:02d}\x1b[45m{:.3f}").format(3, 3.14159) + assert to_formatted_text(value) == [ + ("ansigreen", "0"), + ("ansigreen", "3"), + ("ansigreen bg:ansimagenta", "3"), + ("ansigreen bg:ansimagenta", "."), + ("ansigreen bg:ansimagenta", "1"), + ("ansigreen bg:ansimagenta", "4"), + ("ansigreen bg:ansimagenta", "2"), + ] + + +def test_interpolation(): + value = Template(" {} ").format(HTML("<b>hello</b>")) + + assert to_formatted_text(value) == [ + ("", " "), + ("class:b", "hello"), + ("", " "), + ] + + value = Template("a{}b{}c").format(HTML("<b>hello</b>"), "world") + + assert to_formatted_text(value) == [ + ("", "a"), + ("class:b", "hello"), + ("", "b"), + ("", "world"), + ("", "c"), + ] + + +def test_html_interpolation(): + # %-style interpolation. + value = HTML("<b>%s</b>") % "&hello" + assert to_formatted_text(value) == [("class:b", "&hello")] + + value = HTML("<b>%s</b>") % ("<hello>",) + assert to_formatted_text(value) == [("class:b", "<hello>")] + + value = HTML("<b>%s</b><u>%s</u>") % ("<hello>", "</world>") + assert to_formatted_text(value) == [("class:b", "<hello>"), ("class:u", "</world>")] + + # Format function. + value = HTML("<b>{0}</b><u>{1}</u>").format("'hello'", '"world"') + assert to_formatted_text(value) == [("class:b", "'hello'"), ("class:u", '"world"')] + + value = HTML("<b>{a}</b><u>{b}</u>").format(a="hello", b="world") + assert to_formatted_text(value) == [("class:b", "hello"), ("class:u", "world")] + + value = HTML("<b>{:02d}</b><u>{:.3f}</u>").format(3, 3.14159) + assert to_formatted_text(value) == [("class:b", "03"), ("class:u", "3.142")] + + +def test_merge_formatted_text(): + html1 = HTML("<u>hello</u>") + html2 = HTML("<b>world</b>") + result = merge_formatted_text([html1, html2]) + + assert to_formatted_text(result) == [ + ("class:u", "hello"), + ("class:b", "world"), + ] + + +def test_pygments_tokens(): + text = [ + (("A", "B"), "hello"), # Token.A.B + (("C", "D", "E"), "hello"), # Token.C.D.E + ((), "world"), # Token + ] + + assert to_formatted_text(PygmentsTokens(text)) == [ + ("class:pygments.a.b", "hello"), + ("class:pygments.c.d.e", "hello"), + ("class:pygments", "world"), + ] + + +def test_split_lines(): + lines = list(split_lines([("class:a", "line1\nline2\nline3")])) + + assert lines == [ + [("class:a", "line1")], + [("class:a", "line2")], + [("class:a", "line3")], + ] + + +def test_split_lines_2(): + lines = list( + split_lines([("class:a", "line1"), ("class:b", "line2\nline3\nline4")]) + ) + + assert lines == [ + [("class:a", "line1"), ("class:b", "line2")], + [("class:b", "line3")], + [("class:b", "line4")], + ] + + +def test_split_lines_3(): + "Edge cases: inputs ending with newlines." + # -1- + lines = list(split_lines([("class:a", "line1\nline2\n")])) + + assert lines == [ + [("class:a", "line1")], + [("class:a", "line2")], + [("class:a", "")], + ] + + # -2- + lines = list(split_lines([("class:a", "\n")])) + + assert lines == [ + [], + [("class:a", "")], + ] + + # -3- + lines = list(split_lines([("class:a", "")])) + + assert lines == [ + [("class:a", "")], + ] diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 0000000..500b7f1 --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from asyncio import run + +from prompt_toolkit.history import FileHistory, InMemoryHistory, ThreadedHistory + + +def _call_history_load(history): + """ + Helper: Call the history "load" method and return the result as a list of strings. + """ + result = [] + + async def call_load(): + async for item in history.load(): + result.append(item) + + run(call_load()) + return result + + +def test_in_memory_history(): + history = InMemoryHistory() + history.append_string("hello") + history.append_string("world") + + # Newest should yield first. + assert _call_history_load(history) == ["world", "hello"] + + # Test another call. + assert _call_history_load(history) == ["world", "hello"] + + history.append_string("test3") + assert _call_history_load(history) == ["test3", "world", "hello"] + + # Passing history as a parameter. + history2 = InMemoryHistory(["abc", "def"]) + assert _call_history_load(history2) == ["def", "abc"] + + +def test_file_history(tmpdir): + histfile = tmpdir.join("history") + + history = FileHistory(histfile) + + history.append_string("hello") + history.append_string("world") + + # Newest should yield first. + assert _call_history_load(history) == ["world", "hello"] + + # Test another call. + assert _call_history_load(history) == ["world", "hello"] + + history.append_string("test3") + assert _call_history_load(history) == ["test3", "world", "hello"] + + # Create another history instance pointing to the same file. + history2 = FileHistory(histfile) + assert _call_history_load(history2) == ["test3", "world", "hello"] + + +def test_threaded_file_history(tmpdir): + histfile = tmpdir.join("history") + + history = ThreadedHistory(FileHistory(histfile)) + + history.append_string("hello") + history.append_string("world") + + # Newest should yield first. + assert _call_history_load(history) == ["world", "hello"] + + # Test another call. + assert _call_history_load(history) == ["world", "hello"] + + history.append_string("test3") + assert _call_history_load(history) == ["test3", "world", "hello"] + + # Create another history instance pointing to the same file. + history2 = ThreadedHistory(FileHistory(histfile)) + assert _call_history_load(history2) == ["test3", "world", "hello"] + + +def test_threaded_in_memory_history(): + # Threaded in memory history is not useful. But testing it anyway, just to + # see whether everything plays nicely together. + history = ThreadedHistory(InMemoryHistory()) + history.append_string("hello") + history.append_string("world") + + # Newest should yield first. + assert _call_history_load(history) == ["world", "hello"] + + # Test another call. + assert _call_history_load(history) == ["world", "hello"] + + history.append_string("test3") + assert _call_history_load(history) == ["test3", "world", "hello"] + + # Passing history as a parameter. + history2 = ThreadedHistory(InMemoryHistory(["abc", "def"])) + assert _call_history_load(history2) == ["def", "abc"] diff --git a/tests/test_inputstream.py b/tests/test_inputstream.py new file mode 100644 index 0000000..ab1b036 --- /dev/null +++ b/tests/test_inputstream.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import pytest + +from prompt_toolkit.input.vt100_parser import Vt100Parser +from prompt_toolkit.keys import Keys + + +class _ProcessorMock: + def __init__(self): + self.keys = [] + + def feed_key(self, key_press): + self.keys.append(key_press) + + +@pytest.fixture +def processor(): + return _ProcessorMock() + + +@pytest.fixture +def stream(processor): + return Vt100Parser(processor.feed_key) + + +def test_control_keys(processor, stream): + stream.feed("\x01\x02\x10") + + assert len(processor.keys) == 3 + assert processor.keys[0].key == Keys.ControlA + assert processor.keys[1].key == Keys.ControlB + assert processor.keys[2].key == Keys.ControlP + assert processor.keys[0].data == "\x01" + assert processor.keys[1].data == "\x02" + assert processor.keys[2].data == "\x10" + + +def test_arrows(processor, stream): + stream.feed("\x1b[A\x1b[B\x1b[C\x1b[D") + + assert len(processor.keys) == 4 + assert processor.keys[0].key == Keys.Up + assert processor.keys[1].key == Keys.Down + assert processor.keys[2].key == Keys.Right + assert processor.keys[3].key == Keys.Left + assert processor.keys[0].data == "\x1b[A" + assert processor.keys[1].data == "\x1b[B" + assert processor.keys[2].data == "\x1b[C" + assert processor.keys[3].data == "\x1b[D" + + +def test_escape(processor, stream): + stream.feed("\x1bhello") + + assert len(processor.keys) == 1 + len("hello") + assert processor.keys[0].key == Keys.Escape + assert processor.keys[1].key == "h" + assert processor.keys[0].data == "\x1b" + assert processor.keys[1].data == "h" + + +def test_special_double_keys(processor, stream): + stream.feed("\x1b[1;3D") # Should both send escape and left. + + assert len(processor.keys) == 2 + assert processor.keys[0].key == Keys.Escape + assert processor.keys[1].key == Keys.Left + assert processor.keys[0].data == "\x1b[1;3D" + assert processor.keys[1].data == "" + + +def test_flush_1(processor, stream): + # Send left key in two parts without flush. + stream.feed("\x1b") + stream.feed("[D") + + assert len(processor.keys) == 1 + assert processor.keys[0].key == Keys.Left + assert processor.keys[0].data == "\x1b[D" + + +def test_flush_2(processor, stream): + # Send left key with a 'Flush' in between. + # The flush should make sure that we process everything before as-is, + # with makes the first part just an escape character instead. + stream.feed("\x1b") + stream.flush() + stream.feed("[D") + + assert len(processor.keys) == 3 + assert processor.keys[0].key == Keys.Escape + assert processor.keys[1].key == "[" + assert processor.keys[2].key == "D" + + assert processor.keys[0].data == "\x1b" + assert processor.keys[1].data == "[" + assert processor.keys[2].data == "D" + + +def test_meta_arrows(processor, stream): + stream.feed("\x1b\x1b[D") + + assert len(processor.keys) == 2 + assert processor.keys[0].key == Keys.Escape + assert processor.keys[1].key == Keys.Left + + +def test_control_square_close(processor, stream): + stream.feed("\x1dC") + + assert len(processor.keys) == 2 + assert processor.keys[0].key == Keys.ControlSquareClose + assert processor.keys[1].key == "C" + + +def test_invalid(processor, stream): + # Invalid sequence that has at two characters in common with other + # sequences. + stream.feed("\x1b[*") + + assert len(processor.keys) == 3 + assert processor.keys[0].key == Keys.Escape + assert processor.keys[1].key == "[" + assert processor.keys[2].key == "*" + + +def test_cpr_response(processor, stream): + stream.feed("a\x1b[40;10Rb") + assert len(processor.keys) == 3 + assert processor.keys[0].key == "a" + assert processor.keys[1].key == Keys.CPRResponse + assert processor.keys[2].key == "b" + + +def test_cpr_response_2(processor, stream): + # Make sure that the newline is not included in the CPR response. + stream.feed("\x1b[40;1R\n") + assert len(processor.keys) == 2 + assert processor.keys[0].key == Keys.CPRResponse + assert processor.keys[1].key == Keys.ControlJ diff --git a/tests/test_key_binding.py b/tests/test_key_binding.py new file mode 100644 index 0000000..1c60880 --- /dev/null +++ b/tests/test_key_binding.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +from contextlib import contextmanager + +import pytest + +from prompt_toolkit.application import Application +from prompt_toolkit.application.current import set_app +from prompt_toolkit.input.defaults import create_pipe_input +from prompt_toolkit.key_binding.key_bindings import KeyBindings +from prompt_toolkit.key_binding.key_processor import KeyPress, KeyProcessor +from prompt_toolkit.keys import Keys +from prompt_toolkit.layout import Layout, Window +from prompt_toolkit.output import DummyOutput + + +class Handlers: + def __init__(self): + self.called = [] + + def __getattr__(self, name): + def func(event): + self.called.append(name) + + return func + + +@contextmanager +def set_dummy_app(): + """ + Return a context manager that makes sure that this dummy application is + active. This is important, because we need an `Application` with + `is_done=False` flag, otherwise no keys will be processed. + """ + with create_pipe_input() as pipe_input: + app = Application( + layout=Layout(Window()), + output=DummyOutput(), + input=pipe_input, + ) + + # Don't start background tasks for these tests. The `KeyProcessor` + # wants to create a background task for flushing keys. We can ignore it + # here for these tests. + # This patch is not clean. In the future, when we can use Taskgroups, + # the `Application` should pass its task group to the constructor of + # `KeyProcessor`. That way, it doesn't have to do a lookup using + # `get_app()`. + app.create_background_task = lambda *_, **kw: None + + with set_app(app): + yield + + +@pytest.fixture +def handlers(): + return Handlers() + + +@pytest.fixture +def bindings(handlers): + bindings = KeyBindings() + bindings.add(Keys.ControlX, Keys.ControlC)(handlers.controlx_controlc) + bindings.add(Keys.ControlX)(handlers.control_x) + bindings.add(Keys.ControlD)(handlers.control_d) + bindings.add(Keys.ControlSquareClose, Keys.Any)(handlers.control_square_close_any) + + return bindings + + +@pytest.fixture +def processor(bindings): + return KeyProcessor(bindings) + + +def test_remove_bindings(handlers): + with set_dummy_app(): + h = handlers.controlx_controlc + h2 = handlers.controld + + # Test passing a handler to the remove() function. + bindings = KeyBindings() + bindings.add(Keys.ControlX, Keys.ControlC)(h) + bindings.add(Keys.ControlD)(h2) + assert len(bindings.bindings) == 2 + bindings.remove(h) + assert len(bindings.bindings) == 1 + + # Test passing a key sequence to the remove() function. + bindings = KeyBindings() + bindings.add(Keys.ControlX, Keys.ControlC)(h) + bindings.add(Keys.ControlD)(h2) + assert len(bindings.bindings) == 2 + bindings.remove(Keys.ControlX, Keys.ControlC) + assert len(bindings.bindings) == 1 + + +def test_feed_simple(processor, handlers): + with set_dummy_app(): + processor.feed(KeyPress(Keys.ControlX, "\x18")) + processor.feed(KeyPress(Keys.ControlC, "\x03")) + processor.process_keys() + + assert handlers.called == ["controlx_controlc"] + + +def test_feed_several(processor, handlers): + with set_dummy_app(): + # First an unknown key first. + processor.feed(KeyPress(Keys.ControlQ, "")) + processor.process_keys() + + assert handlers.called == [] + + # Followed by a know key sequence. + processor.feed(KeyPress(Keys.ControlX, "")) + processor.feed(KeyPress(Keys.ControlC, "")) + processor.process_keys() + + assert handlers.called == ["controlx_controlc"] + + # Followed by another unknown sequence. + processor.feed(KeyPress(Keys.ControlR, "")) + processor.feed(KeyPress(Keys.ControlS, "")) + + # Followed again by a know key sequence. + processor.feed(KeyPress(Keys.ControlD, "")) + processor.process_keys() + + assert handlers.called == ["controlx_controlc", "control_d"] + + +def test_control_square_closed_any(processor, handlers): + with set_dummy_app(): + processor.feed(KeyPress(Keys.ControlSquareClose, "")) + processor.feed(KeyPress("C", "C")) + processor.process_keys() + + assert handlers.called == ["control_square_close_any"] + + +def test_common_prefix(processor, handlers): + with set_dummy_app(): + # Sending Control_X should not yet do anything, because there is + # another sequence starting with that as well. + processor.feed(KeyPress(Keys.ControlX, "")) + processor.process_keys() + + assert handlers.called == [] + + # When another key is pressed, we know that we did not meant the longer + # "ControlX ControlC" sequence and the callbacks are called. + processor.feed(KeyPress(Keys.ControlD, "")) + processor.process_keys() + + assert handlers.called == ["control_x", "control_d"] + + +def test_previous_key_sequence(processor): + """ + test whether we receive the correct previous_key_sequence. + """ + with set_dummy_app(): + events = [] + + def handler(event): + events.append(event) + + # Build registry. + registry = KeyBindings() + registry.add("a", "a")(handler) + registry.add("b", "b")(handler) + processor = KeyProcessor(registry) + + # Create processor and feed keys. + processor.feed(KeyPress("a", "a")) + processor.feed(KeyPress("a", "a")) + processor.feed(KeyPress("b", "b")) + processor.feed(KeyPress("b", "b")) + processor.process_keys() + + # Test. + assert len(events) == 2 + assert len(events[0].key_sequence) == 2 + assert events[0].key_sequence[0].key == "a" + assert events[0].key_sequence[0].data == "a" + assert events[0].key_sequence[1].key == "a" + assert events[0].key_sequence[1].data == "a" + assert events[0].previous_key_sequence == [] + + assert len(events[1].key_sequence) == 2 + assert events[1].key_sequence[0].key == "b" + assert events[1].key_sequence[0].data == "b" + assert events[1].key_sequence[1].key == "b" + assert events[1].key_sequence[1].data == "b" + assert len(events[1].previous_key_sequence) == 2 + assert events[1].previous_key_sequence[0].key == "a" + assert events[1].previous_key_sequence[0].data == "a" + assert events[1].previous_key_sequence[1].key == "a" + assert events[1].previous_key_sequence[1].data == "a" diff --git a/tests/test_layout.py b/tests/test_layout.py new file mode 100644 index 0000000..cbbbcd0 --- /dev/null +++ b/tests/test_layout.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import pytest + +from prompt_toolkit.layout import InvalidLayoutError, Layout +from prompt_toolkit.layout.containers import HSplit, VSplit, Window +from prompt_toolkit.layout.controls import BufferControl + + +def test_layout_class(): + c1 = BufferControl() + c2 = BufferControl() + c3 = BufferControl() + win1 = Window(content=c1) + win2 = Window(content=c2) + win3 = Window(content=c3) + + layout = Layout(container=VSplit([HSplit([win1, win2]), win3])) + + # Listing of windows/controls. + assert list(layout.find_all_windows()) == [win1, win2, win3] + assert list(layout.find_all_controls()) == [c1, c2, c3] + + # Focusing something. + layout.focus(c1) + assert layout.has_focus(c1) + assert layout.has_focus(win1) + assert layout.current_control == c1 + assert layout.previous_control == c1 + + layout.focus(c2) + assert layout.has_focus(c2) + assert layout.has_focus(win2) + assert layout.current_control == c2 + assert layout.previous_control == c1 + + layout.focus(win3) + assert layout.has_focus(c3) + assert layout.has_focus(win3) + assert layout.current_control == c3 + assert layout.previous_control == c2 + + # Pop focus. This should focus the previous control again. + layout.focus_last() + assert layout.has_focus(c2) + assert layout.has_focus(win2) + assert layout.current_control == c2 + assert layout.previous_control == c1 + + +def test_create_invalid_layout(): + with pytest.raises(InvalidLayoutError): + Layout(HSplit([])) diff --git a/tests/test_memory_leaks.py b/tests/test_memory_leaks.py new file mode 100644 index 0000000..31ea7c8 --- /dev/null +++ b/tests/test_memory_leaks.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import gc + +import pytest + +from prompt_toolkit.shortcuts.prompt import PromptSession + + +def _count_prompt_session_instances() -> int: + # Run full GC collection first. + gc.collect() + + # Count number of remaining referenced `PromptSession` instances. + objects = gc.get_objects() + return len([obj for obj in objects if isinstance(obj, PromptSession)]) + + +# Fails in GitHub CI, probably due to GC differences. +@pytest.mark.xfail(reason="Memory leak testing fails in GitHub CI.") +def test_prompt_session_memory_leak() -> None: + before_count = _count_prompt_session_instances() + + # Somehow in CI/CD, the before_count is > 0 + assert before_count == 0 + + p = PromptSession() + + after_count = _count_prompt_session_instances() + assert after_count == before_count + 1 + + del p + + after_delete_count = _count_prompt_session_instances() + assert after_delete_count == before_count diff --git a/tests/test_print_formatted_text.py b/tests/test_print_formatted_text.py new file mode 100644 index 0000000..26c7265 --- /dev/null +++ b/tests/test_print_formatted_text.py @@ -0,0 +1,92 @@ +""" +Test the `print` function. +""" +from __future__ import annotations + +import pytest + +from prompt_toolkit import print_formatted_text as pt_print +from prompt_toolkit.formatted_text import HTML, FormattedText, to_formatted_text +from prompt_toolkit.output import ColorDepth +from prompt_toolkit.styles import Style +from prompt_toolkit.utils import is_windows + + +class _Capture: + "Emulate an stdout object." + + def __init__(self): + self._data = [] + + def write(self, data): + self._data.append(data) + + @property + def data(self): + return "".join(self._data) + + def flush(self): + pass + + def isatty(self): + return True + + def fileno(self): + # File descriptor is not used for printing formatted text. + # (It is only needed for getting the terminal size.) + return -1 + + +@pytest.mark.skipif(is_windows(), reason="Doesn't run on Windows yet.") +def test_print_formatted_text(): + f = _Capture() + pt_print([("", "hello"), ("", "world")], file=f) + assert "hello" in f.data + assert "world" in f.data + + +@pytest.mark.skipif(is_windows(), reason="Doesn't run on Windows yet.") +def test_print_formatted_text_backslash_r(): + f = _Capture() + pt_print("hello\r\n", file=f) + assert "hello" in f.data + + +@pytest.mark.skipif(is_windows(), reason="Doesn't run on Windows yet.") +def test_formatted_text_with_style(): + f = _Capture() + style = Style.from_dict( + { + "hello": "#ff0066", + "world": "#44ff44 italic", + } + ) + tokens = FormattedText( + [ + ("class:hello", "Hello "), + ("class:world", "world"), + ] + ) + + # NOTE: We pass the default (8bit) color depth, so that the unit tests + # don't start failing when environment variables change. + pt_print(tokens, style=style, file=f, color_depth=ColorDepth.DEFAULT) + assert "\x1b[0;38;5;197mHello" in f.data + assert "\x1b[0;38;5;83;3mworld" in f.data + + +@pytest.mark.skipif(is_windows(), reason="Doesn't run on Windows yet.") +def test_html_with_style(): + """ + Text `print_formatted_text` with `HTML` wrapped in `to_formatted_text`. + """ + f = _Capture() + + html = HTML("<ansigreen>hello</ansigreen> <b>world</b>") + formatted_text = to_formatted_text(html, style="class:myhtml") + pt_print(formatted_text, file=f, color_depth=ColorDepth.DEFAULT) + + assert ( + f.data + == "\x1b[0m\x1b[?7h\x1b[0;32mhello\x1b[0m \x1b[0;1mworld\x1b[0m\r\n\x1b[0m" + ) diff --git a/tests/test_regular_languages.py b/tests/test_regular_languages.py new file mode 100644 index 0000000..deef6b8 --- /dev/null +++ b/tests/test_regular_languages.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from prompt_toolkit.completion import CompleteEvent, Completer, Completion +from prompt_toolkit.contrib.regular_languages import compile +from prompt_toolkit.contrib.regular_languages.compiler import Match, Variables +from prompt_toolkit.contrib.regular_languages.completion import GrammarCompleter +from prompt_toolkit.document import Document + + +def test_simple_match(): + g = compile("hello|world") + + m = g.match("hello") + assert isinstance(m, Match) + + m = g.match("world") + assert isinstance(m, Match) + + m = g.match("somethingelse") + assert m is None + + +def test_variable_varname(): + """ + Test `Variable` with varname. + """ + g = compile("((?P<varname>hello|world)|test)") + + m = g.match("hello") + variables = m.variables() + assert isinstance(variables, Variables) + assert variables.get("varname") == "hello" + assert variables["varname"] == "hello" + + m = g.match("world") + variables = m.variables() + assert isinstance(variables, Variables) + assert variables.get("varname") == "world" + assert variables["varname"] == "world" + + m = g.match("test") + variables = m.variables() + assert isinstance(variables, Variables) + assert variables.get("varname") is None + assert variables["varname"] is None + + +def test_prefix(): + """ + Test `match_prefix`. + """ + g = compile(r"(hello\ world|something\ else)") + + m = g.match_prefix("hello world") + assert isinstance(m, Match) + + m = g.match_prefix("he") + assert isinstance(m, Match) + + m = g.match_prefix("") + assert isinstance(m, Match) + + m = g.match_prefix("som") + assert isinstance(m, Match) + + m = g.match_prefix("hello wor") + assert isinstance(m, Match) + + m = g.match_prefix("no-match") + assert m.trailing_input().start == 0 + assert m.trailing_input().stop == len("no-match") + + m = g.match_prefix("hellotest") + assert m.trailing_input().start == len("hello") + assert m.trailing_input().stop == len("hellotest") + + +def test_completer(): + class completer1(Completer): + def get_completions(self, document, complete_event): + yield Completion("before-%s-after" % document.text, -len(document.text)) + yield Completion("before-%s-after-B" % document.text, -len(document.text)) + + class completer2(Completer): + def get_completions(self, document, complete_event): + yield Completion("before2-%s-after2" % document.text, -len(document.text)) + yield Completion("before2-%s-after2-B" % document.text, -len(document.text)) + + # Create grammar. "var1" + "whitespace" + "var2" + g = compile(r"(?P<var1>[a-z]*) \s+ (?P<var2>[a-z]*)") + + # Test 'get_completions()' + completer = GrammarCompleter(g, {"var1": completer1(), "var2": completer2()}) + completions = list( + completer.get_completions(Document("abc def", len("abc def")), CompleteEvent()) + ) + + assert len(completions) == 2 + assert completions[0].text == "before2-def-after2" + assert completions[0].start_position == -3 + assert completions[1].text == "before2-def-after2-B" + assert completions[1].start_position == -3 diff --git a/tests/test_shortcuts.py b/tests/test_shortcuts.py new file mode 100644 index 0000000..287c6d3 --- /dev/null +++ b/tests/test_shortcuts.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from prompt_toolkit.shortcuts import print_container +from prompt_toolkit.shortcuts.prompt import _split_multiline_prompt +from prompt_toolkit.widgets import Frame, TextArea + + +def test_split_multiline_prompt(): + # Test 1: no newlines: + tokens = [("class:testclass", "ab")] + has_before_tokens, before, first_input_line = _split_multiline_prompt( + lambda: tokens + ) + assert has_before_tokens() is False + assert before() == [] + assert first_input_line() == [ + ("class:testclass", "a"), + ("class:testclass", "b"), + ] + + # Test 1: multiple lines. + tokens = [("class:testclass", "ab\ncd\nef")] + has_before_tokens, before, first_input_line = _split_multiline_prompt( + lambda: tokens + ) + assert has_before_tokens() is True + assert before() == [ + ("class:testclass", "a"), + ("class:testclass", "b"), + ("class:testclass", "\n"), + ("class:testclass", "c"), + ("class:testclass", "d"), + ] + assert first_input_line() == [ + ("class:testclass", "e"), + ("class:testclass", "f"), + ] + + # Edge case 1: starting with a newline. + tokens = [("class:testclass", "\nab")] + has_before_tokens, before, first_input_line = _split_multiline_prompt( + lambda: tokens + ) + assert has_before_tokens() is True + assert before() == [] + assert first_input_line() == [("class:testclass", "a"), ("class:testclass", "b")] + + # Edge case 2: starting with two newlines. + tokens = [("class:testclass", "\n\nab")] + has_before_tokens, before, first_input_line = _split_multiline_prompt( + lambda: tokens + ) + assert has_before_tokens() is True + assert before() == [("class:testclass", "\n")] + assert first_input_line() == [("class:testclass", "a"), ("class:testclass", "b")] + + +def test_print_container(tmpdir): + # Call `print_container`, render to a dummy file. + f = tmpdir.join("output") + with open(f, "w") as fd: + print_container(Frame(TextArea(text="Hello world!\n"), title="Title"), file=fd) + + # Verify rendered output. + with open(f) as fd: + text = fd.read() + assert "Hello world" in text + assert "Title" in text diff --git a/tests/test_style.py b/tests/test_style.py new file mode 100644 index 0000000..d0a4790 --- /dev/null +++ b/tests/test_style.py @@ -0,0 +1,276 @@ +from __future__ import annotations + +from prompt_toolkit.styles import Attrs, Style, SwapLightAndDarkStyleTransformation + + +def test_style_from_dict(): + style = Style.from_dict( + { + "a": "#ff0000 bold underline strike italic", + "b": "bg:#00ff00 blink reverse", + } + ) + + # Lookup of class:a. + expected = Attrs( + color="ff0000", + bgcolor="", + bold=True, + underline=True, + strike=True, + italic=True, + blink=False, + reverse=False, + hidden=False, + ) + assert style.get_attrs_for_style_str("class:a") == expected + + # Lookup of class:b. + expected = Attrs( + color="", + bgcolor="00ff00", + bold=False, + underline=False, + strike=False, + italic=False, + blink=True, + reverse=True, + hidden=False, + ) + assert style.get_attrs_for_style_str("class:b") == expected + + # Test inline style. + expected = Attrs( + color="ff0000", + bgcolor="", + bold=False, + underline=False, + strike=False, + italic=False, + blink=False, + reverse=False, + hidden=False, + ) + assert style.get_attrs_for_style_str("#ff0000") == expected + + # Combine class name and inline style (Whatever is defined later gets priority.) + expected = Attrs( + color="00ff00", + bgcolor="", + bold=True, + underline=True, + strike=True, + italic=True, + blink=False, + reverse=False, + hidden=False, + ) + assert style.get_attrs_for_style_str("class:a #00ff00") == expected + + expected = Attrs( + color="ff0000", + bgcolor="", + bold=True, + underline=True, + strike=True, + italic=True, + blink=False, + reverse=False, + hidden=False, + ) + assert style.get_attrs_for_style_str("#00ff00 class:a") == expected + + +def test_class_combinations_1(): + # In this case, our style has both class 'a' and 'b'. + # Given that the style for 'a b' is defined at the end, that one is used. + style = Style( + [ + ("a", "#0000ff"), + ("b", "#00ff00"), + ("a b", "#ff0000"), + ] + ) + expected = Attrs( + color="ff0000", + bgcolor="", + bold=False, + underline=False, + strike=False, + italic=False, + blink=False, + reverse=False, + hidden=False, + ) + assert style.get_attrs_for_style_str("class:a class:b") == expected + assert style.get_attrs_for_style_str("class:a,b") == expected + assert style.get_attrs_for_style_str("class:a,b,c") == expected + + # Changing the order shouldn't matter. + assert style.get_attrs_for_style_str("class:b class:a") == expected + assert style.get_attrs_for_style_str("class:b,a") == expected + + +def test_class_combinations_2(): + # In this case, our style has both class 'a' and 'b'. + # The style that is defined the latest get priority. + style = Style( + [ + ("a b", "#ff0000"), + ("b", "#00ff00"), + ("a", "#0000ff"), + ] + ) + expected = Attrs( + color="00ff00", + bgcolor="", + bold=False, + underline=False, + strike=False, + italic=False, + blink=False, + reverse=False, + hidden=False, + ) + assert style.get_attrs_for_style_str("class:a class:b") == expected + assert style.get_attrs_for_style_str("class:a,b") == expected + assert style.get_attrs_for_style_str("class:a,b,c") == expected + + # Defining 'a' latest should give priority to 'a'. + expected = Attrs( + color="0000ff", + bgcolor="", + bold=False, + underline=False, + strike=False, + italic=False, + blink=False, + reverse=False, + hidden=False, + ) + assert style.get_attrs_for_style_str("class:b class:a") == expected + assert style.get_attrs_for_style_str("class:b,a") == expected + + +def test_substyles(): + style = Style( + [ + ("a.b", "#ff0000 bold"), + ("a", "#0000ff"), + ("b", "#00ff00"), + ("b.c", "#0000ff italic"), + ] + ) + + # Starting with a.* + expected = Attrs( + color="0000ff", + bgcolor="", + bold=False, + underline=False, + strike=False, + italic=False, + blink=False, + reverse=False, + hidden=False, + ) + assert style.get_attrs_for_style_str("class:a") == expected + + expected = Attrs( + color="ff0000", + bgcolor="", + bold=True, + underline=False, + strike=False, + italic=False, + blink=False, + reverse=False, + hidden=False, + ) + assert style.get_attrs_for_style_str("class:a.b") == expected + assert style.get_attrs_for_style_str("class:a.b.c") == expected + + # Starting with b.* + expected = Attrs( + color="00ff00", + bgcolor="", + bold=False, + underline=False, + strike=False, + italic=False, + blink=False, + reverse=False, + hidden=False, + ) + assert style.get_attrs_for_style_str("class:b") == expected + assert style.get_attrs_for_style_str("class:b.a") == expected + + expected = Attrs( + color="0000ff", + bgcolor="", + bold=False, + underline=False, + strike=False, + italic=True, + blink=False, + reverse=False, + hidden=False, + ) + assert style.get_attrs_for_style_str("class:b.c") == expected + assert style.get_attrs_for_style_str("class:b.c.d") == expected + + +def test_swap_light_and_dark_style_transformation(): + transformation = SwapLightAndDarkStyleTransformation() + + # Test with 6 digit hex colors. + before = Attrs( + color="440000", + bgcolor="888844", + bold=True, + underline=True, + strike=True, + italic=True, + blink=False, + reverse=False, + hidden=False, + ) + after = Attrs( + color="ffbbbb", + bgcolor="bbbb76", + bold=True, + underline=True, + strike=True, + italic=True, + blink=False, + reverse=False, + hidden=False, + ) + + assert transformation.transform_attrs(before) == after + + # Test with ANSI colors. + before = Attrs( + color="ansired", + bgcolor="ansiblack", + bold=True, + underline=True, + strike=True, + italic=True, + blink=False, + reverse=False, + hidden=False, + ) + after = Attrs( + color="ansibrightred", + bgcolor="ansiwhite", + bold=True, + underline=True, + strike=True, + italic=True, + blink=False, + reverse=False, + hidden=False, + ) + + assert transformation.transform_attrs(before) == after diff --git a/tests/test_style_transformation.py b/tests/test_style_transformation.py new file mode 100644 index 0000000..e4eee7c --- /dev/null +++ b/tests/test_style_transformation.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import pytest + +from prompt_toolkit.styles import AdjustBrightnessStyleTransformation, Attrs + + +@pytest.fixture +def default_attrs(): + return Attrs( + color="", + bgcolor="", + bold=False, + underline=False, + strike=False, + italic=False, + blink=False, + reverse=False, + hidden=False, + ) + + +def test_adjust_brightness_style_transformation(default_attrs): + tr = AdjustBrightnessStyleTransformation(0.5, 1.0) + + attrs = tr.transform_attrs(default_attrs._replace(color="ff0000")) + assert attrs.color == "ff7f7f" + + attrs = tr.transform_attrs(default_attrs._replace(color="00ffaa")) + assert attrs.color == "7fffd4" + + # When a background color is given, nothing should change. + attrs = tr.transform_attrs(default_attrs._replace(color="00ffaa", bgcolor="white")) + assert attrs.color == "00ffaa" + + # Test ansi colors. + attrs = tr.transform_attrs(default_attrs._replace(color="ansiblue")) + assert attrs.color == "6666ff" + + # Test 'ansidefault'. This shouldn't change. + attrs = tr.transform_attrs(default_attrs._replace(color="ansidefault")) + assert attrs.color == "ansidefault" + + # When 0 and 1 are given, don't do any style transformation. + tr2 = AdjustBrightnessStyleTransformation(0, 1) + + attrs = tr2.transform_attrs(default_attrs._replace(color="ansiblue")) + assert attrs.color == "ansiblue" + + attrs = tr2.transform_attrs(default_attrs._replace(color="00ffaa")) + assert attrs.color == "00ffaa" diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..9d4c808 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import itertools + +import pytest + +from prompt_toolkit.utils import take_using_weights + + +def test_using_weights(): + def take(generator, count): + return list(itertools.islice(generator, 0, count)) + + # Check distribution. + data = take(take_using_weights(["A", "B", "C"], [5, 10, 20]), 35) + assert data.count("A") == 5 + assert data.count("B") == 10 + assert data.count("C") == 20 + + assert data == [ + "A", + "B", + "C", + "C", + "B", + "C", + "C", + "A", + "B", + "C", + "C", + "B", + "C", + "C", + "A", + "B", + "C", + "C", + "B", + "C", + "C", + "A", + "B", + "C", + "C", + "B", + "C", + "C", + "A", + "B", + "C", + "C", + "B", + "C", + "C", + ] + + # Another order. + data = take(take_using_weights(["A", "B", "C"], [20, 10, 5]), 35) + assert data.count("A") == 20 + assert data.count("B") == 10 + assert data.count("C") == 5 + + # Bigger numbers. + data = take(take_using_weights(["A", "B", "C"], [20, 10, 5]), 70) + assert data.count("A") == 40 + assert data.count("B") == 20 + assert data.count("C") == 10 + + # Negative numbers. + data = take(take_using_weights(["A", "B", "C"], [-20, 10, 0]), 70) + assert data.count("A") == 0 + assert data.count("B") == 70 + assert data.count("C") == 0 + + # All zero-weight items. + with pytest.raises(ValueError): + take(take_using_weights(["A", "B", "C"], [0, 0, 0]), 70) diff --git a/tests/test_vt100_output.py b/tests/test_vt100_output.py new file mode 100644 index 0000000..65c8377 --- /dev/null +++ b/tests/test_vt100_output.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from prompt_toolkit.output.vt100 import _get_closest_ansi_color + + +def test_get_closest_ansi_color(): + # White + assert _get_closest_ansi_color(255, 255, 255) == "ansiwhite" + assert _get_closest_ansi_color(250, 250, 250) == "ansiwhite" + + # Black + assert _get_closest_ansi_color(0, 0, 0) == "ansiblack" + assert _get_closest_ansi_color(5, 5, 5) == "ansiblack" + + # Green + assert _get_closest_ansi_color(0, 255, 0) == "ansibrightgreen" + assert _get_closest_ansi_color(10, 255, 0) == "ansibrightgreen" + assert _get_closest_ansi_color(0, 255, 10) == "ansibrightgreen" + + assert _get_closest_ansi_color(220, 220, 100) == "ansiyellow" diff --git a/tests/test_widgets.py b/tests/test_widgets.py new file mode 100644 index 0000000..ee7745a --- /dev/null +++ b/tests/test_widgets.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from prompt_toolkit.formatted_text import fragment_list_to_text +from prompt_toolkit.layout import to_window +from prompt_toolkit.widgets import Button + + +def _to_text(button: Button) -> str: + control = to_window(button).content + return fragment_list_to_text(control.text()) + + +def test_default_button(): + button = Button("Exit") + assert _to_text(button) == "< Exit >" + + +def test_custom_button(): + button = Button("Exit", left_symbol="[", right_symbol="]") + assert _to_text(button) == "[ Exit ]" diff --git a/tests/test_yank_nth_arg.py b/tests/test_yank_nth_arg.py new file mode 100644 index 0000000..7167a26 --- /dev/null +++ b/tests/test_yank_nth_arg.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import pytest + +from prompt_toolkit.buffer import Buffer +from prompt_toolkit.history import InMemoryHistory + + +@pytest.fixture +def _history(): + "Prefilled history." + history = InMemoryHistory() + history.append_string("alpha beta gamma delta") + history.append_string("one two three four") + return history + + +# Test yank_last_arg. + + +def test_empty_history(): + buf = Buffer() + buf.yank_last_arg() + assert buf.document.current_line == "" + + +def test_simple_search(_history): + buff = Buffer(history=_history) + buff.yank_last_arg() + assert buff.document.current_line == "four" + + +def test_simple_search_with_quotes(_history): + _history.append_string("""one two "three 'x' four"\n""") + buff = Buffer(history=_history) + buff.yank_last_arg() + assert buff.document.current_line == '''"three 'x' four"''' + + +def test_simple_search_with_arg(_history): + buff = Buffer(history=_history) + buff.yank_last_arg(n=2) + assert buff.document.current_line == "three" + + +def test_simple_search_with_arg_out_of_bounds(_history): + buff = Buffer(history=_history) + buff.yank_last_arg(n=8) + assert buff.document.current_line == "" + + +def test_repeated_search(_history): + buff = Buffer(history=_history) + buff.yank_last_arg() + buff.yank_last_arg() + assert buff.document.current_line == "delta" + + +def test_repeated_search_with_wraparound(_history): + buff = Buffer(history=_history) + buff.yank_last_arg() + buff.yank_last_arg() + buff.yank_last_arg() + assert buff.document.current_line == "four" + + +# Test yank_last_arg. + + +def test_yank_nth_arg(_history): + buff = Buffer(history=_history) + buff.yank_nth_arg() + assert buff.document.current_line == "two" + + +def test_repeated_yank_nth_arg(_history): + buff = Buffer(history=_history) + buff.yank_nth_arg() + buff.yank_nth_arg() + assert buff.document.current_line == "beta" + + +def test_yank_nth_arg_with_arg(_history): + buff = Buffer(history=_history) + buff.yank_nth_arg(n=2) + assert buff.document.current_line == "three" diff --git a/tools/debug_input_cross_platform.py b/tools/debug_input_cross_platform.py new file mode 100755 index 0000000..55f6190 --- /dev/null +++ b/tools/debug_input_cross_platform.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +""" +Read input and print keys. +For testing terminal input. + +Works on both Windows and Posix. +""" +import asyncio + +from prompt_toolkit.input import create_input +from prompt_toolkit.keys import Keys + + +async def main() -> None: + done = asyncio.Event() + input = create_input() + + def keys_ready(): + for key_press in input.read_keys(): + print(key_press) + + if key_press.key == Keys.ControlC: + done.set() + + with input.raw_mode(): + with input.attach(keys_ready): + await done.wait() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tools/debug_vt100_input.py b/tools/debug_vt100_input.py new file mode 100755 index 0000000..d3660b9 --- /dev/null +++ b/tools/debug_vt100_input.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python +""" +Parse vt100 input and print keys. +For testing terminal input. + +(This does not use the `Input` implementation, but only the `Vt100Parser`.) +""" +import sys + +from prompt_toolkit.input.vt100 import raw_mode +from prompt_toolkit.input.vt100_parser import Vt100Parser +from prompt_toolkit.keys import Keys + + +def callback(key_press): + print(key_press) + + if key_press.key == Keys.ControlC: + sys.exit(0) + + +def main(): + stream = Vt100Parser(callback) + + with raw_mode(sys.stdin.fileno()): + while True: + c = sys.stdin.read(1) + stream.feed(c) + + +if __name__ == "__main__": + main() diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..310f076 --- /dev/null +++ b/tox.ini @@ -0,0 +1,13 @@ +# Tox (http://tox.testrun.org/) is a tool for running tests +# in multiple virtualenvs. This configuration file will run the +# test suite on all supported python versions. To use it, "pip install tox" +# and then run "tox" from this directory. + +[tox] +envlist = py{37, 38, 39, 310, 311, 312, py3} + +[testenv] +commands = pytest [] + +deps= + pytest -- cgit v1.2.3