diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-15 16:35:31 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-15 16:35:31 +0000 |
commit | 4f1a3b5f9ad05aa7b08715d48909a2b06ee2fcb1 (patch) | |
tree | e5dee7be2f0d963da4faad6517278d03783e3adc | |
parent | Initial commit. (diff) | |
download | prompt-toolkit-4f1a3b5f9ad05aa7b08715d48909a2b06ee2fcb1.tar.xz prompt-toolkit-4f1a3b5f9ad05aa7b08715d48909a2b06ee2fcb1.zip |
Adding upstream version 3.0.43.upstream/3.0.43
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
364 files changed, 59334 insertions, 0 deletions
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 <jonathan AT slenders.be> + +Contributors +------------ + +- Amjith Ramanujam <amjith.r AT gmail.com> 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 <esc> 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 <num>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 @@ -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 <http://github.com/prompt-toolkit/ptpython/>`_: Python REPL +- `ptpdb <http://github.com/jonathanslenders/ptpdb/>`_: Python debugger (pdb replacement) +- `pgcli <https://www.pgcli.com/>`_: Postgres client. +- `mycli <https://www.mycli.net/>`_: MySql client. +- `litecli <https://litecli.com/>`_: SQLite client. +- `wharfee <http://wharfee.com/>`_: A Docker command line. +- `xonsh <http://xon.sh/>`_: A Python-ish, BASHwards-compatible shell. +- `saws <https://github.com/donnemartin/saws>`_: A Supercharged AWS Command Line Interface. +- `cycli <https://github.com/nicolewhite/cycli>`_: A Command Line Interface for Cypher. +- `crash <https://github.com/crate/crash>`_: Crate command line client. +- `vcli <https://github.com/dbcli/vcli>`_: Vertica client. +- `aws-shell <https://github.com/awslabs/aws-shell>`_: An integrated shell for working with the AWS CLI. +- `softlayer-python <https://github.com/softlayer/softlayer-python>`_: A command-line interface to manage various SoftLayer products and services. +- `ipython <http://github.com/ipython/ipython/>`_: The IPython REPL +- `click-repl <https://github.com/click-contrib/click-repl>`_: Subcommand REPL for click apps. +- `haxor-news <https://github.com/donnemartin/haxor-news>`_: A Hacker News CLI. +- `gitsome <https://github.com/donnemartin/gitsome>`_: A Git/Shell Autocompleter with GitHub Integration. +- `http-prompt <https://github.com/eliangcs/http-prompt>`_: An interactive command-line HTTP client. +- `coconut <http://coconut-lang.org/>`_: Functional programming in Python. +- `Ergonomica <https://github.com/ergonomica/ergonomica>`_: A Bash alternative written in Python. +- `Kube-shell <https://github.com/cloudnativelabs/kube-shell>`_: Kubernetes shell: An integrated shell for working with the Kubernetes CLI +- `mssql-cli <https://github.com/dbcli/mssql-cli>`_: A command-line client for Microsoft SQL Server. +- `robotframework-debuglibrary <https://github.com/xyb/robotframework-debuglibrary>`_: A debug library and REPL for RobotFramework. +- `ptrepl <https://github.com/imomaliev/ptrepl>`_: Run any command as REPL +- `clipwdmgr <https://github.com/samisalkosuo/clipasswordmgr>`_: Command Line Password Manager. +- `slacker <https://github.com/netromdk/slacker>`_: Easy access to the Slack API and admin of workspaces via REPL. +- `EdgeDB <https://edgedb.com/>`_: The next generation object-relational database. +- `pywit <https://github.com/wit-ai/pywit>`_: Python library for Wit.ai. +- `objection <https://github.com/sensepost/objection>`_: Runtime Mobile Exploration. +- `habu <https://github.com/portantier/habu>`_: Python Network Hacking Toolkit. +- `nawano <https://github.com/rbw/nawano>`_: Nano cryptocurrency wallet +- `athenacli <https://github.com/dbcli/athenacli>`_: A CLI for AWS Athena. +- `vulcano <https://github.com/dgarana/vulcano>`_: A framework for creating command-line applications that also runs in REPL mode. +- `kafka-shell <https://github.com/devshawn/kafka-shell>`_: A supercharged shell for Apache Kafka. +- `starterTree <https://github.com/thomas10-10/starterTree>`_: A command launcher organized in a tree structure with fuzzy autocompletion +- `git-delete-merged-branches <https://github.com/hartwork/git-delete-merged-branches>`_: Command-line tool to delete merged Git branches + +Full screen applications: + +- `pymux <http://github.com/prompt-toolkit/pymux/>`_: A terminal multiplexer (like tmux) in pure Python. +- `pyvim <http://github.com/prompt-toolkit/pyvim/>`_: A Vim clone in pure Python. +- `freud <http://github.com/stloma/freud/>`_: REST client backed by SQLite for storing servers +- `pypager <https://github.com/prompt-toolkit/pypager>`_: A $PAGER in pure Python (like "less"). +- `kubeterminal <https://github.com/samisalkosuo/kubeterminal>`_: Kubectl helper tool. +- `pydoro <https://github.com/JaDogg/pydoro>`_: Pomodoro timer. +- `sanctuary-zero <https://github.com/t0xic0der/sanctuary-zero>`_: A secure chatroom with zero logging and total transience. +- `Hummingbot <https://github.com/CoinAlpha/hummingbot>`_: A Cryptocurrency Algorithmic Trading Platform +- `git-bbb <https://github.com/MrMino/git-bbb>`_: A `git blame` browser. + +Libraries: + +- `ptterm <https://github.com/prompt-toolkit/ptterm>`_: A terminal emulator widget for prompt_toolkit. +- `PyInquirer <https://github.com/CITGuru/PyInquirer/>`_: A Python library that wants to make it easy for existing Inquirer.js users to write immersive command line applications in Python. +- `clintermission <https://github.com/sebageek/clintermission>`_: Non-fullscreen command-line selection menu + +Other libraries and implementations in other languages +****************************************************** + +- `go-prompt <https://github.com/c-bata/go-prompt>`_: building a powerful + interactive prompt in Go, inspired by python-prompt-toolkit. +- `urwid <http://urwid.org/>`_: 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 +<http://python-prompt-toolkit.readthedocs.io/en/stable/>`_. + + +Gallery +******* + +`ptpython <http://github.com/prompt-toolkit/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 <https://python-prompt-toolkit.readthedocs.io/en/stable/pages/gallery.html>`_ + + +prompt_toolkit features +*********************** + +``prompt_toolkit`` could be a replacement for `GNU readline +<https://tiswww.case.edu/php/chet/readline/rltop.html>`_, 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 <https://cirw.in/blog/bracketed-paste>`_. +- Mouse support for cursor positioning and scrolling. +- Auto suggestions. (Like `fish shell <http://fishshell.com/>`_.) +- 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 +<http://cmder.net/>`_ or `conemu <https://conemu.github.io/>`_. + +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 <PROJECTS.rst>`_ +*********************************************** + +Special thanks to +***************** + +- `Pygments <http://pygments.org/>`_: Syntax highlighter. +- `wcwidth <https://github.com/jquast/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 <target>' where <target> 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 +# "<project> v<release> 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 <link> 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 Binary files differnew file mode 100644 index 0000000..7671156 --- /dev/null +++ b/docs/images/auto-suggestion.png diff --git a/docs/images/bottom-toolbar.png b/docs/images/bottom-toolbar.png Binary files differnew file mode 100644 index 0000000..8d5f17c --- /dev/null +++ b/docs/images/bottom-toolbar.png diff --git a/docs/images/colored-prompt.png b/docs/images/colored-prompt.png Binary files differnew file mode 100644 index 0000000..bd85975 --- /dev/null +++ b/docs/images/colored-prompt.png diff --git a/docs/images/colorful-completions.png b/docs/images/colorful-completions.png Binary files differnew file mode 100644 index 0000000..c397815 --- /dev/null +++ b/docs/images/colorful-completions.png diff --git a/docs/images/dialogs/button.png b/docs/images/dialogs/button.png Binary files differnew file mode 100644 index 0000000..fb4b3bb --- /dev/null +++ b/docs/images/dialogs/button.png diff --git a/docs/images/dialogs/confirm.png b/docs/images/dialogs/confirm.png Binary files differnew file mode 100644 index 0000000..1b7d9ec --- /dev/null +++ b/docs/images/dialogs/confirm.png diff --git a/docs/images/dialogs/inputbox.png b/docs/images/dialogs/inputbox.png Binary files differnew file mode 100644 index 0000000..73ae79f --- /dev/null +++ b/docs/images/dialogs/inputbox.png diff --git a/docs/images/dialogs/messagebox.png b/docs/images/dialogs/messagebox.png Binary files differnew file mode 100644 index 0000000..3ac08c0 --- /dev/null +++ b/docs/images/dialogs/messagebox.png diff --git a/docs/images/dialogs/styled.png b/docs/images/dialogs/styled.png Binary files differnew file mode 100644 index 0000000..a359a7a --- /dev/null +++ b/docs/images/dialogs/styled.png diff --git a/docs/images/hello-world-prompt.png b/docs/images/hello-world-prompt.png Binary files differnew file mode 100644 index 0000000..17e7e1f --- /dev/null +++ b/docs/images/hello-world-prompt.png diff --git a/docs/images/html-completion.png b/docs/images/html-completion.png Binary files differnew file mode 100644 index 0000000..8fd73ad --- /dev/null +++ b/docs/images/html-completion.png diff --git a/docs/images/html-input.png b/docs/images/html-input.png Binary files differnew file mode 100644 index 0000000..1f4b11b --- /dev/null +++ b/docs/images/html-input.png diff --git a/docs/images/logo_400px.png b/docs/images/logo_400px.png Binary files differnew file mode 100755 index 0000000..5d90d44 --- /dev/null +++ b/docs/images/logo_400px.png diff --git a/docs/images/multiline-input.png b/docs/images/multiline-input.png Binary files differnew file mode 100644 index 0000000..7d72844 --- /dev/null +++ b/docs/images/multiline-input.png diff --git a/docs/images/number-validator.png b/docs/images/number-validator.png Binary files differnew file mode 100644 index 0000000..5a12c89 --- /dev/null +++ b/docs/images/number-validator.png diff --git a/docs/images/progress-bars/apt-get.png b/docs/images/progress-bars/apt-get.png Binary files differnew file mode 100755 index 0000000..ce62464 --- /dev/null +++ b/docs/images/progress-bars/apt-get.png diff --git a/docs/images/progress-bars/colored-title-and-label.png b/docs/images/progress-bars/colored-title-and-label.png Binary files differnew file mode 100755 index 0000000..ace4393 --- /dev/null +++ b/docs/images/progress-bars/colored-title-and-label.png diff --git a/docs/images/progress-bars/custom-key-bindings.png b/docs/images/progress-bars/custom-key-bindings.png Binary files differnew file mode 100755 index 0000000..5f3610c --- /dev/null +++ b/docs/images/progress-bars/custom-key-bindings.png diff --git a/docs/images/progress-bars/simple-progress-bar.png b/docs/images/progress-bars/simple-progress-bar.png Binary files differnew file mode 100755 index 0000000..6ea3eac --- /dev/null +++ b/docs/images/progress-bars/simple-progress-bar.png diff --git a/docs/images/progress-bars/two-tasks.png b/docs/images/progress-bars/two-tasks.png Binary files differnew file mode 100755 index 0000000..0bb3f75 --- /dev/null +++ b/docs/images/progress-bars/two-tasks.png diff --git a/docs/images/ptpython-2.png b/docs/images/ptpython-2.png Binary files differnew file mode 100644 index 0000000..0fce32c --- /dev/null +++ b/docs/images/ptpython-2.png diff --git a/docs/images/ptpython-history-help.png b/docs/images/ptpython-history-help.png Binary files differnew file mode 100644 index 0000000..a52b5c2 --- /dev/null +++ b/docs/images/ptpython-history-help.png diff --git a/docs/images/ptpython-menu.png b/docs/images/ptpython-menu.png Binary files differnew file mode 100644 index 0000000..923ca12 --- /dev/null +++ b/docs/images/ptpython-menu.png diff --git a/docs/images/ptpython.png b/docs/images/ptpython.png Binary files differnew file mode 100644 index 0000000..c46b55a --- /dev/null +++ b/docs/images/ptpython.png diff --git a/docs/images/pymux.png b/docs/images/pymux.png Binary files differnew file mode 100644 index 0000000..7e8d73a --- /dev/null +++ b/docs/images/pymux.png diff --git a/docs/images/pyvim.png b/docs/images/pyvim.png Binary files differnew file mode 100644 index 0000000..f78000f --- /dev/null +++ b/docs/images/pyvim.png diff --git a/docs/images/repl/sqlite-1.png b/docs/images/repl/sqlite-1.png Binary files differnew file mode 100644 index 0000000..0511daa --- /dev/null +++ b/docs/images/repl/sqlite-1.png diff --git a/docs/images/repl/sqlite-2.png b/docs/images/repl/sqlite-2.png Binary files differnew file mode 100644 index 0000000..47b0238 --- /dev/null +++ b/docs/images/repl/sqlite-2.png diff --git a/docs/images/repl/sqlite-3.png b/docs/images/repl/sqlite-3.png Binary files differnew file mode 100644 index 0000000..cdee9d2 --- /dev/null +++ b/docs/images/repl/sqlite-3.png diff --git a/docs/images/repl/sqlite-4.png b/docs/images/repl/sqlite-4.png Binary files differnew file mode 100644 index 0000000..c6ee929 --- /dev/null +++ b/docs/images/repl/sqlite-4.png diff --git a/docs/images/repl/sqlite-5.png b/docs/images/repl/sqlite-5.png Binary files differnew file mode 100644 index 0000000..d1964e0 --- /dev/null +++ b/docs/images/repl/sqlite-5.png diff --git a/docs/images/repl/sqlite-6.png b/docs/images/repl/sqlite-6.png Binary files differnew file mode 100644 index 0000000..054beb0 --- /dev/null +++ b/docs/images/repl/sqlite-6.png diff --git a/docs/images/rprompt.png b/docs/images/rprompt.png Binary files differnew file mode 100644 index 0000000..a44f0e9 --- /dev/null +++ b/docs/images/rprompt.png 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 +<http://cnswww.cns.cwru.edu/php/chet/readline/rltop.html>`_, 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 <http://fishshell.com/>`_.) +- 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 + <https://docs.python.org/3/library/asyncio.html>`_ process. + +Have a look at :ref:`the gallery <gallery>` to get an idea of what is possible. + +Getting started +--------------- + +Go to :ref:`getting started <getting_started>` and build your first prompt. +Issues are tracked `on the Github project +<https://github.com/prompt-toolkit/python-prompt-toolkit>`_. + + +Thanks to: +---------- + +A special thanks to `all the contributors +<https://github.com/prompt-toolkit/python-prompt-toolkit/graphs/contributors>`_ +for making prompt_toolkit possible. + +Also, a special thanks to the `Pygments <http://pygments.org/>`_ and `wcwidth +<https://github.com/jquast/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 +<http://github.com/prompt-toolkit/>`_. 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 ^<target^>` where ^<target^> 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 <https://ipython.org/>`_, 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 '<any>' key: + +.. code:: python + + @bindings.add('a', '<any>') + 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 <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 ``<sigint>``. For instance: + +.. code:: python + + @bindings.add('<sigint>') + 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 ``<sigint>`` 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 ...<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 <http://pygments.org/>`_ +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 <http://pygments.org/>`_ 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 <http://pygments.org/>`_ 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 <http://pygments.org/>`_ 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 <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 +<http://pygments.org/>`_ 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 <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 <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 +<formatted_text>`. 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(['<html>', '<body>', '<head>', '<title>']) + 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 --- /dev/null +++ b/src/prompt_toolkit/contrib/__init__.py 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 --- /dev/null +++ b/src/prompt_toolkit/key_binding/bindings/__init__.py 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 --- /dev/null +++ b/src/prompt_toolkit/py.typed 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() @@ -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 |