From aeab7ecc3aa1aa60927858b04621ca8f933aae4a Mon Sep 17 00:00:00 2001 From: Jonathan Slenders Date: Sat, 30 Aug 2014 12:03:25 +0200 Subject: [PATCH] Initial commit (Squash of about 68 commits between Jan 25, 2014 and August 30, 2014.) --- .gitignore | 36 + CHANGELOG | 3 + LICENSE | 27 + README.rst | 108 ++ bin/ptpython | 35 + docs/Makefile | 177 ++++ docs/conf.py | 258 +++++ docs/images/ptpython-complete-menu.png | Bin 0 -> 21056 bytes docs/images/ptpython-screenshot.png | Bin 0 -> 30361 bytes docs/index.rst | 52 + docs/make.bat | 242 +++++ docs/pages/architecture.rst | 100 ++ docs/pages/example.rst | 12 + docs/pages/reference.rst | 41 + examples/example.py | 86 ++ examples/get-input.py | 8 + examples/get-multiline-input.py | 9 + examples/html-input.py | 34 + examples/pdb.py | 264 +++++ prompt_toolkit/__init__.py | 254 +++++ prompt_toolkit/code.py | 136 +++ prompt_toolkit/contrib/__init__.py | 0 prompt_toolkit/contrib/repl.py | 600 +++++++++++ prompt_toolkit/contrib/shell/__init__.py | 0 prompt_toolkit/contrib/shell/code.py | 73 ++ prompt_toolkit/contrib/shell/completers.py | 28 + prompt_toolkit/contrib/shell/lexer.py | 132 +++ prompt_toolkit/contrib/shell/nodes.py | 249 +++++ prompt_toolkit/contrib/shell/prompt.py | 34 + prompt_toolkit/contrib/shell/rules.py | 375 +++++++ prompt_toolkit/contrib/shortcuts.py | 38 + prompt_toolkit/document.py | 297 ++++++ prompt_toolkit/enums.py | 15 + prompt_toolkit/history.py | 66 ++ prompt_toolkit/inputstream.py | 180 ++++ prompt_toolkit/inputstream_handler.py | 1085 ++++++++++++++++++++ prompt_toolkit/libs/__init__.py | 0 prompt_toolkit/libs/wcwidth.py | 349 +++++++ prompt_toolkit/line.py | 987 ++++++++++++++++++ prompt_toolkit/prompt.py | 105 ++ prompt_toolkit/render_context.py | 23 + prompt_toolkit/renderer.py | 513 +++++++++ prompt_toolkit/utils.py | 65 ++ setup.py | 27 + tests.py | 621 +++++++++++ 45 files changed, 7744 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG create mode 100644 LICENSE create mode 100644 README.rst create mode 100755 bin/ptpython create mode 100644 docs/Makefile create mode 100644 docs/conf.py create mode 100644 docs/images/ptpython-complete-menu.png create mode 100644 docs/images/ptpython-screenshot.png create mode 100644 docs/index.rst create mode 100644 docs/make.bat create mode 100644 docs/pages/architecture.rst create mode 100644 docs/pages/example.rst create mode 100644 docs/pages/reference.rst create mode 100755 examples/example.py create mode 100755 examples/get-input.py create mode 100755 examples/get-multiline-input.py create mode 100755 examples/html-input.py create mode 100755 examples/pdb.py create mode 100644 prompt_toolkit/__init__.py create mode 100644 prompt_toolkit/code.py create mode 100644 prompt_toolkit/contrib/__init__.py create mode 100644 prompt_toolkit/contrib/repl.py create mode 100644 prompt_toolkit/contrib/shell/__init__.py create mode 100644 prompt_toolkit/contrib/shell/code.py create mode 100644 prompt_toolkit/contrib/shell/completers.py create mode 100644 prompt_toolkit/contrib/shell/lexer.py create mode 100644 prompt_toolkit/contrib/shell/nodes.py create mode 100644 prompt_toolkit/contrib/shell/prompt.py create mode 100644 prompt_toolkit/contrib/shell/rules.py create mode 100644 prompt_toolkit/contrib/shortcuts.py create mode 100644 prompt_toolkit/document.py create mode 100644 prompt_toolkit/enums.py create mode 100644 prompt_toolkit/history.py create mode 100644 prompt_toolkit/inputstream.py create mode 100644 prompt_toolkit/inputstream_handler.py create mode 100644 prompt_toolkit/libs/__init__.py create mode 100644 prompt_toolkit/libs/wcwidth.py create mode 100644 prompt_toolkit/line.py create mode 100644 prompt_toolkit/prompt.py create mode 100644 prompt_toolkit/render_context.py create mode 100644 prompt_toolkit/renderer.py create mode 100644 prompt_toolkit/utils.py create mode 100644 setup.py create mode 100644 tests.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..ded606788 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +*.py[cod] + +# C extensions +*.so + +# Packages +*.egg +*.egg-info +dist +build +eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg +lib +lib64 +__pycache__ + +# Installer logs +pip-log.txt + +# Unit test / coverage reports +.coverage +.tox +nosetests.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 000000000..622ab214a --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,3 @@ +Jan 25, 2014 + +first commit diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..e1720e0fb --- /dev/null +++ b/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2014, Jonathan Slenders +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. + +* Neither the name of the {organization} nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.rst b/README.rst new file mode 100644 index 000000000..8b233f321 --- /dev/null +++ b/README.rst @@ -0,0 +1,108 @@ +Python Prompt Toolkit +===================== + +(Work in progress. Many things work, but APIs can change.) + + +``prompt_toolkit`` is a Library for building powerful interactive command lines +in Python. It ships with a nice interative Python shell built on top of the +library. + +``prompt_toolkit`` can be a replacement for ``readline``. + +Features: + +- Pure Python. +- Syntax highlighting of the input while typing. (For instance, with a Pygments lexer.) +- Multiline input editing +- Advanced code completion +- Both Emacs and Vi keybindings (Similar to readline) +- Reverse and forward incremental search +- Both Python 3 and Python 2.7 support +- Works well with Unicode double width characters. (Chinese input.) +- Code written with love. + + +Limitations: + +- Only for vt100-compatible terminals. (Actually, all terminals in OS X and + Linux systems are VT100 compatible the days, so that should not be an issue. + There is no Windows support, however.) + + +Installation +------------ + +:: + + pip install prompt-toolkit + + +The Python repl +--------------- + +Run ``ptpython`` to get an interactive Python prompt with syntaxt highlighting, +code completion, etc... + +.. image :: docs/images/ptpython-screenshot.png + +If you prefer to have Vi keybindings (which currently are more completely +implemented than the Emacs bindings), run ``ptpython --vi``. + +If you want to embed the repl inside your application at one point, do: + +.. code:: python + + from prompt_toolkit.contrib.repl import embed + embed(globals(), locals(), vi_mode=False, history_filename=None) + +Autocompletion +************** + +``Tab`` and ``shift+tab`` complete the input. (Thanks to the `Jedi +`_ autocompletion library.) +In Vi-mode, you can also use ``Ctrl+N`` and ``Ctrl+P``. + +.. image :: docs/images/ptpython-complete-menu.png + + +Multiline editing +***************** + +Usually, multiline editing mode will automatically turn on when you press enter +after a colon, however you can always turn it on by pressing ``F7``. + +To execute the input in multiline mode, you can either press ``Alt+Enter``, or +``Esc`` followed by ``Enter``. (If you want the first to work in the OS X +terminal, you have to check the "Use option as meta key" checkbox in your +terminal settings.) + + +Using as a library +------------------ + +This is a library which allows you to build highly customizable input prompts. +Every step (from key bindings, to line behaviour until the renderer can be +customized.) + +A simple example looks like this: + +.. code:: python + + from prompt_toolkit import CommandLine, AbortAction + from prompt_toolkit.line import Exit + + def main(): + # Create CommandLine instance + cli = CommandLine() + + try: + while True: + code_obj = cli.read_input(on_exit=AbortAction.RAISE_EXCEPTION) + print('You said: ' + code_obj.text) + + except Exit: # Quit on Ctrl-D keypress + return + + if __name__ == '__main__': + main() diff --git a/bin/ptpython b/bin/ptpython new file mode 100755 index 000000000..a91968d45 --- /dev/null +++ b/bin/ptpython @@ -0,0 +1,35 @@ +#!/usr/bin/env python +""" +ptpython: Interactive Python shell. +Usage: + ptpython [ --vi ] [( --history FILENAME )] [ --no-colors ] + ptpython -h | --help + +Options: + --vi : Use Vi keybindings instead of Emacs bindings. +""" +import docopt +import os + +from prompt_toolkit.contrib.repl import embed + +def _run_repl(): + a = docopt.docopt(__doc__) + + vi_mode = bool(a['--vi']) + no_colors = bool(a['--no-colors']) + + # Create globals/locals dict. + globals_, locals_ = {}, {} + + if a['FILENAME']: + history_filename = os.path.expanduser(a['FILENAME']) + else: + history_filename = os.path.expanduser('~/.ptpython_history') + + + # Run interactive shell. + embed(globals_, locals_, vi_mode=vi_mode, history_filename=history_filename, no_colors=no_colors) + +if __name__ == '__main__': + _run_repl() diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 000000000..d5ddb2ddd --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,177 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext + +help: + @echo "Please use \`make ' where is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " xml to make Docutils-native XML files" + @echo " pseudoxml to make pseudoxml-XML files for display purposes" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/prompt_toolkit.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/prompt_toolkit.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/prompt_toolkit" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/prompt_toolkit" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +latexpdfja: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through platex and dvipdfmx..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." + +xml: + $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml + @echo + @echo "Build finished. The XML files are in $(BUILDDIR)/xml." + +pseudoxml: + $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml + @echo + @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 000000000..2b3106ede --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,258 @@ +# -*- coding: utf-8 -*- +# +# 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. + +import sys +import os + +# 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' ] + +# 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 = u'prompt_toolkit' +copyright = u'2014, 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 = '0.0.1' +# The full version, including alpha/beta/rc tags. +release = '0.0.1' + +# 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 = 'sphinx' + +# 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 + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'default' + +# 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. +#html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +#html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +#html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +#html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +#html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +#html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +#html_extra_path = [] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +#html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +#html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +#html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +#html_additional_pages = {} + +# If false, no module index is generated. +#html_domain_indices = True + +# If false, no index is generated. +#html_use_index = True + +# If true, the index is split into individual pages for each letter. +#html_split_index = False + +# If true, links to the reST sources are added to the pages. +#html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +#html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +#html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +#html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +#html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'prompt_toolkitdoc' + + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { +# The paper size ('letterpaper' or 'a4paper'). +#'papersize': 'letterpaper', + +# The font size ('10pt', '11pt' or '12pt'). +#'pointsize': '10pt', + +# Additional stuff for the LaTeX preamble. +#'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + ('index', 'prompt_toolkit.tex', u'prompt_toolkit Documentation', + u'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', u'prompt_toolkit Documentation', + [u'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', u'prompt_toolkit Documentation', + u'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/ptpython-complete-menu.png b/docs/images/ptpython-complete-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..2bdb18d2861bc0817d3e098b96702cfccf1a5c8c GIT binary patch literal 21056 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 literal 0 HcmV?d00001 diff --git a/docs/images/ptpython-screenshot.png b/docs/images/ptpython-screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..82d4fc3d81936b013714a5da93b071cfea20c3e3 GIT binary patch literal 30361 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 literal 0 HcmV?d00001 diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 000000000..12c20e90e --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,52 @@ +.. documentation master file, created by + sphinx-quickstart on Thu Jul 31 14:17:08 2014. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Python Prompt Toolkit +===================== + +`prompt_toolkit` is a Library for building interactive command lines in Python. + +It could be a replacement for `readline`. It's Pure Python, and has some +advanced features: + +- Syntax highlighting of the input while typing. (Usually with a Pygments lexer.) +- Multiline input +- Advanced code completion +- Both Emacs and Vi keybindings (Similar to readline), including + reverse and forward incremental search + + +On top of that, it implements `prompt_toolkit.shell`, a library for shell-like +interfaces. You can define the grammar of the input string, ... + +Thanks to: + + - Pygments + - wcwidth + - + + Chapters + -------- + + - Simple example. (Most simple example, alternative to raw_input.) + - Architecture of a line + - + +.. toctree:: + :maxdepth: 3 + + pages/example + pages/repl + pages/architecture + pages/reference + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 000000000..18ae02283 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,242 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +set I18NSPHINXOPTS=%SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^` where ^ is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. xml to make Docutils-native XML files + echo. pseudoxml to make pseudoxml-XML files for display purposes + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + + +%SPHINXBUILD% 2> nul +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\xline.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\xline.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdf" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf + cd %BUILDDIR%/.. + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "latexpdfja" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + cd %BUILDDIR%/latex + make all-pdf-ja + cd %BUILDDIR%/.. + echo. + echo.Build finished; the PDF files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +if "%1" == "xml" ( + %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The XML files are in %BUILDDIR%/xml. + goto end +) + +if "%1" == "pseudoxml" ( + %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. + goto end +) + +:end diff --git a/docs/pages/architecture.rst b/docs/pages/architecture.rst new file mode 100644 index 000000000..9c5a169c9 --- /dev/null +++ b/docs/pages/architecture.rst @@ -0,0 +1,100 @@ +Architecture +============ + + +:: + + +---------------------------------------------------------------+ + | 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 `InputStreamHandel` instance. | + | | + | e.g. Translate '\x1b[6~' into "page_down", call the | + | `page_down` function of `InputStreamHandler` | + +---------------------------------------------------------------+ + | + v + +---------------------------------------------------------------+ + | InputStreamHandler | + | ================== | + | - Implements keybindings for control keys, arrow | + | movement, escape, etc... There are two classes | + | inheriting from this, which implement more | + | specific key bindings. | + | * `EmacsInputStreamHandler` | + | * `ViInputStreamHandler` | + | Keybindings are implemented as operations of the | + | `Line` object. | + | | + | e.g. 'ctrl_t' calls the | + | `swap_characters_before_cursor` method of | + | the `Line` object. | + +---------------------------------------------------------------+ + | + v + +---------------------------------------------------------------+ + | Line | + | ==== | + | - 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 | | + | | `Line` 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. | | + | +-----------------------------------------------+ | + +---------------------------------------------------------------+ + | + | `Line` creates a `RenderContext` instance which holds the + | information to visualise the command line. This is passed + | to the `Renderer` object. (Passing this object happens at + | various places.) + | + | +---------------+ +-------------------------------+ + | | RenderContext | | Prompt | + | | ============= | --> | ====== | + | | | | - Responsible for the | + | | | | "prompt" (The leading text | + | | | | before the actual input.) | + | | | | | + | | | | Further it actually also | + | | | | implements the trailing | + | | | | text, which could be a | + | | | | context sentsitive help | + | | | | text. | + | | | +-------------------------------+ + | | | + | | | +-------------------------------+ + | | | -> | Code | + | | | | ==== | + | | | | - Implements the semantics | + | | | | of the command line. This | + | | | | are two major things: | + | | | | | + | | | | * tokenizing the input | + | | | | for highlighting. | + | | | | * Autocompletion | + | +---------------+ +-------------------------------+ + | + | The methods from `Prompt` and `Code` which are meant for + | the renderer return a list of (Token, text) tuples, where + | `Token` is a Pygments token. + v + +---------------------------------------------------------------+ + | Renderer | + | ======== | + | - Responsible for painting the (Token, text) tuples | + | to the terminal output. | + +---------------------------------------------------------------+ + + diff --git a/docs/pages/example.rst b/docs/pages/example.rst new file mode 100644 index 000000000..f6c6c1ce6 --- /dev/null +++ b/docs/pages/example.rst @@ -0,0 +1,12 @@ +Examples +======== + +(TODO) + +- raw_input alternative +- cmd.Cmd alternative +- Python shell embed +- Custom grammar + + + diff --git a/docs/pages/reference.rst b/docs/pages/reference.rst new file mode 100644 index 000000000..55c14080b --- /dev/null +++ b/docs/pages/reference.rst @@ -0,0 +1,41 @@ +Reference +========= + +Inputstream +----------- + +.. automodule:: prompt_toolkit.inputstream + :members: + +Inputstream handler +------------------- + +.. automodule:: prompt_toolkit.inputstream_handler + :members: + +Line +---- + +.. automodule:: prompt_toolkit.line + :members: + +Code +---- + +.. automodule:: prompt_toolkit.code + :members: + +Prompt +------ + +.. automodule:: prompt_toolkit.prompt + :members: + +Renderer +-------- + +.. automodule:: prompt_toolkit.renderer + :members: + +.. automodule:: prompt_toolkit.render_context + :members: diff --git a/examples/example.py b/examples/example.py new file mode 100755 index 000000000..7ba58126d --- /dev/null +++ b/examples/example.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python +""" + +Work in progress. + +Alternative for a shell (like Bash). + +""" + +from pygments.style import Style +from pygments.token import Token + +from prompt_toolkit import CommandLine, AbortAction +from prompt_toolkit.contrib.shell.code import ShellCode +from prompt_toolkit.contrib.shell.completers import Path +from prompt_toolkit.contrib.shell.prompt import ShellPrompt +from prompt_toolkit.contrib.shell.rules import Any, Sequence, Literal, Repeat, Variable +from prompt_toolkit.line import Exit + + +class OurGitCode(ShellCode): + rule = Any([ + Sequence([ + Any([ + Literal('cd'), + Literal('ls'), + Literal('pushd'), + ]), + Variable(Path, placeholder='') ]), + + Literal('pwd'), + Sequence([Literal('rm'), Variable(Path, placeholder='') ]), + Sequence([Literal('cp'), Variable(Path, placeholder=''), Variable(Path, placeholder='') ]), + #Sequence([Literal('cp'), Repeat(Variable(Path, placeholder='')), Variable(Path, placeholder='') ]), + Sequence([Literal('git'), Repeat( + Any([ + #Sequence([]), + Literal('--version'), + Sequence([Literal('-c'), Variable(placeholder='=')]), + Sequence([Literal('--exec-path'), Variable(Path, placeholder='')]), + Literal('--help'), + ]) + ), + Any([ + Sequence([ Literal('checkout'), Variable(placeholder='') ]), + Sequence([ Literal('clone'), Variable(placeholder='') ]), + Sequence([ Literal('diff'), Variable(placeholder='') ]), + ]), + ]), + Sequence([Literal('echo'), Repeat(Variable(placeholder='')), ]), + ]) + + + +class ExampleStyle(Style): + background_color = None + styles = { + Token.Placeholder: "#aa8888", + Token.Placeholder.Variable: "#aa8888", + Token.Placeholder.Bracket: "bold #ff7777", + Token.Placeholder.Separator: "#ee7777", +# A.Path: '#0044aa', +# A.Param: '#ff00ff', + Token.Aborted: '#aaaaaa', + } + + +class ExampleCommandLine(CommandLine): + code_cls = OurGitCode + style_cls = ExampleStyle + prompt_cls = ShellPrompt + + +if __name__ == '__main__': + cli = ExampleCommandLine() + + try: + while True: + shell_code = cli.read_input(on_exit=AbortAction.RAISE_EXCEPTION) + parse_info = shell_code.get_parse_info() + + print ('You said: %r' % parse_info) + print(parse_info.get_variables()) + + except Exit: + pass diff --git a/examples/get-input.py b/examples/get-input.py new file mode 100755 index 000000000..8c028bf15 --- /dev/null +++ b/examples/get-input.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python +from __future__ import unicode_literals +from prompt_toolkit.contrib.shortcuts import get_input + + +if __name__ == '__main__': + answer = get_input('Give me some input: ') + print('You said: %s' % answer) diff --git a/examples/get-multiline-input.py b/examples/get-multiline-input.py new file mode 100755 index 000000000..573b66cb6 --- /dev/null +++ b/examples/get-multiline-input.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python +from __future__ import unicode_literals +from prompt_toolkit.contrib.shortcuts import get_input + + +if __name__ == '__main__': + print('Press [Meta+Enter] or [Esc] followed by [Enter] to accept input.') + answer = get_input('Give me some multiline input: ', multiline=True) + print('You said: %s' % answer) diff --git a/examples/html-input.py b/examples/html-input.py new file mode 100755 index 000000000..c11b10034 --- /dev/null +++ b/examples/html-input.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python +""" +Simple example of a syntax-highlighted HTML input line. +""" +from pygments.lexers import HtmlLexer + +from prompt_toolkit import CommandLine +from prompt_toolkit.code import Code +from prompt_toolkit.prompt import Prompt + + +class HtmlCode(Code): + lexer_cls = HtmlLexer + + +class HtmlPrompt(Prompt): + default_prompt_text = 'Enter HTML: ' + + +class HtmlCommandLine(CommandLine): + code_cls = HtmlCode + prompt_cls = HtmlPrompt + + + +def main(): + cli = HtmlCommandLine() + + html_code_obj = cli.read_input() + print('You said: ' + html_code_obj.text) + + +if __name__ == '__main__': + main() diff --git a/examples/pdb.py b/examples/pdb.py new file mode 100755 index 000000000..6f07e913f --- /dev/null +++ b/examples/pdb.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python +""" + +Work in progress version of a PDB prompt. + +""" + +from pygments.style import Style +from pygments.token import Token +from pygments.token import Keyword, Operator, Number, Name, Error, Comment + +from prompt_toolkit import CommandLine, AbortAction +from prompt_toolkit.contrib.shell.code import ShellCode +from prompt_toolkit.contrib.shell.prompt import ShellPrompt +from prompt_toolkit.contrib.shell.rules import Any, Sequence, Literal, Variable, Repeat +from prompt_toolkit.line import Exit +from prompt_toolkit.prompt import Prompt + +from prompt_toolkit.contrib.repl import PythonCode + + +class PdbCode(ShellCode): + rule = Any([ + Sequence([ + Any([ + Literal('b', dest='cmd_break'), + Literal('break', dest='cmd_break'), + Literal('tbreak', dest='tbreak'), + ]), + Variable(placeholder='', dest='break_line'), + Variable(placeholder='', dest='break_condition'), + ]), + Sequence([ + Literal('condition', dest='cmd_condition'), + Variable(placeholder='', dest='condition_bpnumber'), + Variable(placeholder='', dest='condition_str') + ]), + Sequence([ + Any([ + Literal('l', dest='cmd_list'), + Literal('list', dest='cmd_list'), + ]), + Variable(placeholder='', dest='list_first'), + Variable(placeholder='', dest='list_last') + ]), + Sequence([ + Literal('debug'), + Variable(placeholder=''), + ]), + Sequence([ + Literal('disable'), + Variable(placeholder=''), + ]), + Sequence([ + Literal('enable'), + Repeat(Variable(placeholder='')), + ]), + Sequence([ + Literal('ignore'), + Variable(placeholder=''), + Variable(placeholder=''), + ]), + Sequence([ + Literal('run'), + Repeat(Variable(placeholder='')), + ]), + Sequence([ + Literal('alias'), + Variable(placeholder=''), + Variable(placeholder=''), + Repeat(Variable(placeholder='')), + ]), + Sequence([ + Any([ + Literal('h'), + Literal('help'), + ]), + Variable(placeholder=''), + ]), + Sequence([ + Literal('unalias'), + Variable(placeholder=''), + ]), + Sequence([ + Literal('jump'), + Variable(placeholder=''), + ]), + Sequence([ + Literal('whatis'), + Variable(placeholder=''), + ]), + Sequence([ + Literal('pp'), + Variable(placeholder=''), + ]), + Sequence([ + Literal('p'), + Variable(placeholder=''), + ]), + Sequence([ + Any([ + Literal('cl'), + Literal('clear'), + ]), + Repeat(Variable(placeholder='')), + ]), + Any([ + Literal('a'), + Literal('args'), + ]), + Any([ + Literal('cont'), + Literal('continue'), + ]), + Any([ + Literal('d'), + Literal('down'), + ]), + Any([ + Literal('exit'), + Literal('q'), + Literal('quit'), + ]), + Any([ + Literal('n'), + Literal('next'), + ]), + Any([ + Literal('run'), + Literal('restart'), + ]), + Any([ + Literal('unt'), + Literal('until'), + ]), + Any([ + Literal('u'), + Literal('up'), + ]), + Any([ + Literal('r'), + Literal('return'), + ]), + Any([ + Literal('w', dest='where'), + Literal('where', dest='where'), + Literal('bt'), + ]), + Any([ + Literal('s'), + Literal('step'), + ]), + Literal('commands'), + ]) + +class PythonOrPdbCode(object): + def __init__(self, document): + self._pdb_code = PdbCode(document) + self._python_code = PythonCode(document, {}, {}) + self._document = document + + @property + def is_pdb_statement(self): + if self._document.text == '': + return True + + try: + # Try to excess the first parse tree + bool(self._pdb_code.get_parse_info()) + return True + except Exception: + return False + + def __getattr__(self, name): + if self.is_pdb_statement: + return getattr(self._pdb_code, name) + else: + result = getattr(self._python_code, name) + return result + + def complete(self): + # Always return completions for the PDB Shell, if none were found, + # return completions as it is Python code. + return self._pdb_code.complete() or self._python_code.complete() + + +class PdbPrompt(ShellPrompt): + def _get_lex_result(self): + return self.code._pdb_code._get_lex_result() + + def get_default_prompt(self): + yield (Token.Prompt, '(pdb) ') + + +class PythonPrompt(Prompt): + def get_default_prompt(self): + yield (Token.Prompt, '(pdb) ') + + +class PdbOrPythonprompt(object): + """ + Create a prompt class that proxies around `PdbPrompt` if the input is valid + PDB shell input, otherwise proxies around `PythonPrompt`. + """ + def __init__(self, line, code): + self._pdb_prompt = PdbPrompt(line, code) + self._python_prompt = PythonPrompt(line, code) + self._code = code + + def __getattr__(self, name): + if self._code.is_pdb_statement: + return getattr(self._pdb_prompt, name) + else: + return getattr(self._python_prompt, name) + + +class PdbStyle(Style): + background_color = None + styles = { + # Pdb commands highlighting. + Token.Placeholder: "#aa8888", + Token.Placeholder.Variable: "#aa8888", + Token.Placeholder.Bracket: "bold #ff7777", + Token.Placeholder.Separator: "#ee7777", + Token.Aborted: "#aaaaaa", + Token.Prompt: "bold", + + # Python code highlighting. + Keyword: '#ee00ee', + Operator: '#aa6666', + Number: '#ff0000', + Name: '#008800', + Token.Literal.String: '#440000', + Comment: '#0000dd', + Error: '#000000 bg:#ff8888', + } + + +class PdbCommandLine(CommandLine): + code_cls = PythonOrPdbCode + prompt_cls = PdbOrPythonprompt + +# # << +# code_cls = PdbCode +# prompt_cls = PdbPrompt +# # >> + + style_cls = PdbStyle + + +if __name__ == '__main__': + cli = PdbCommandLine() + + try: + while True: + code = cli.read_input(on_exit=AbortAction.RAISE_EXCEPTION) + + if code.is_pdb_statement: + print ('PDB command: %r' % code.get_parse_info().get_variables()) + else: + print ('Python command: %r' % code.text) + + except Exit: + pass diff --git a/prompt_toolkit/__init__.py b/prompt_toolkit/__init__.py new file mode 100644 index 000000000..830958337 --- /dev/null +++ b/prompt_toolkit/__init__.py @@ -0,0 +1,254 @@ +""" + +prompt_toolkit +-------------- + +Pure Python alternative to readline. + +Still experimental and incomplete. It should be able to handle RAW vt100 input +sequences for a command line and construct a command line with autocompletion +there. + +Author: Jonathan Slenders + +""" +from __future__ import unicode_literals + +import codecs +import fcntl +import os +import select +import six +import sys +import errno + +from .code import Code +from .inputstream import InputStream +from .inputstream_handler import InputStreamHandler +from .line import Line, Exit, ReturnInput, Abort +from .prompt import Prompt +from .renderer import Renderer +from .utils import raw_mode, call_on_sigwinch +from .history import History + +from pygments.styles.default import DefaultStyle + +__all__ = ( + 'AbortAction', + 'CommandLine', +) + +class AbortAction: + """ + Actions to take on an Exit or Abort exception. + """ + IGNORE = 'ignore' + RETRY = 'retry' + RAISE_EXCEPTION = 'raise-exception' + RETURN_NONE = 'return-none' + + +class CommandLine(object): + """ + Wrapper around all the other classes, tying everything together. + """ + # TODO: rename `_cls` suffixes to `_factory` + + #: The `Line` class which implements the text manipulation. + line_cls = Line + + #: A `Code` class which implements the interpretation of the text input. + #: It tokenizes/parses the input text. + code_cls = Code + + #: `Prompt` class for the layout of the prompt. (and the help text.) + prompt_cls = Prompt + + #: `InputStream` class for the parser of the input + #: (Normally, you don't override this one.) + inputstream_cls = InputStream + + #: `InputStreamHandler` class for the keybindings. + inputstream_handler_cls = InputStreamHandler + + #: `Renderer` class. + renderer_cls = Renderer + + #: `pygments.style.Style` class for the syntax highlighting. + style_cls = DefaultStyle + + #: `History` class. + history_cls = History + + #: Boolean to indicate whether we will have other threads communicating + #: with the input event loop. + enable_concurency = False + + def __init__(self, stdin=None, stdout=None): + self.stdin = stdin or sys.stdin + self.stdout = stdout or sys.stdout + + # In case of Python2, sys.stdin.read() returns bytes instead of unicode + # characters. By wrapping it in getreader('utf-8'), we make sure to + # read valid unicode characters. + if not six.PY3: + self.stdin = codecs.getreader('utf-8')(sys.stdin) + + self._renderer = self.renderer_cls(self.stdout, style=self.style_cls) + self._line = self.line_cls(renderer=self._renderer, + code_cls=self.code_cls, prompt_cls=self.prompt_cls, + history_cls=self.history_cls) + self._inputstream_handler = self.inputstream_handler_cls(self._line) + + # Pipe for inter thread communication. + self._redraw_pipe = None + + def request_redraw(self): + """ + Thread safe way of sending a repaint trigger to the input event loop. + """ + if self._redraw_pipe: + os.write(self._redraw_pipe[1], b'x') + + def _redraw(self): + """ + Render the command line again. (Not thread safe!) + (From other threads, or if unsure, use `request_redraw`.) + """ + self._renderer.render(self._line.get_render_context()) + + def on_input_timeout(self, code_obj): + """ + Called when there is no input for x seconds. + """ + # At this place, you can for instance start a background thread to + # generate information about the input. E.g. the code signature of the + # function below the cursor position in the case of a REPL. + pass + + def _get_char_loop(self): + """ + The input 'event loop'. + + This should return the next character to process. + """ + timeout = .5 + + while True: + r, w, x = _select([self.stdin, self._redraw_pipe[0]], [], [], timeout) + + if self.stdin in r: + # Read the input and return it. + # 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. + return os.read(self.stdin.fileno(), 1024).decode('utf-8') + + # If we receive something on our redraw pipe, render again. + elif self._redraw_pipe[0] in r: + # Flush all the pipe content and repaint. + os.read(self._redraw_pipe[0], 1024) + self._redraw() + timeout = None + else: + # + self.on_input_timeout(self._line.create_code_obj()) + timeout = None + + def read_input(self, initial_value='', on_abort=AbortAction.RETRY, on_exit=AbortAction.IGNORE): + """ + Read input from command line. + This can raise `Exit`, when the user presses Ctrl-D. + """ + # Create a pipe for inter thread communication. + if self.enable_concurency: + self._redraw_pipe = os.pipe() + + # Make the read-end of this pipe non blocking. + fcntl.fcntl(self._redraw_pipe[0], fcntl.F_SETFL, os.O_NONBLOCK) + + # TODO: create renderer here. (We want a new rendere instance for each input.) + # `_line` should not need the renderer instance... + # (use exceptions there to print completion pagers.) + + stream = self.inputstream_cls(self._inputstream_handler, stdout=self.stdout) + + def reset_line(): + # Reset line + self._line.reset(initial_value=initial_value) + + with raw_mode(self.stdin): + reset_line() + self._redraw() + + with call_on_sigwinch(self._redraw): + while True: + if self.enable_concurency: + c = self._get_char_loop() + else: + c = self.stdin.read(1) + + # If we got a character, feed it to the input stream. If we + # got none, it means we got a repaint request. + if c: + try: + # Feed one character at a time. Feeding can cause the + # `Line` object to raise Exit/Abort/ReturnInput + stream.feed(c) + + except Exit as e: + # Handle exit. + if on_exit != AbortAction.IGNORE: + self._renderer.render(e.render_context) + + if on_exit == AbortAction.RAISE_EXCEPTION: + raise + elif on_exit == AbortAction.RETURN_NONE: + return None + elif on_exit == AbortAction.RETRY: + reset_line() + + except Abort as abort: + # Handle abort. + if on_abort != AbortAction.IGNORE: + self._renderer.render(abort.render_context) + + if on_abort == AbortAction.RAISE_EXCEPTION: + raise + elif on_abort == AbortAction.RETURN_NONE: + return None + elif on_abort == AbortAction.RETRY: + reset_line() + + except ReturnInput as input: + self._renderer.render(input.render_context) + return input.document + + # TODO: completions should be 'rendered' as well through an exception. + + # Now render the current prompt to the output. + # TODO: unless `select` tells us that there's another character to feed. + self._redraw() + + if self._redraw_pipe: + os.close(self._redraw_pipe[0]) + os.close(self._redraw_pipe[1]) + + +def _select(*args, **kwargs): + """ + Wrapper around select.select. + + When the SIGWINCH signal is handled, other system calls, like select + are aborted in Python. This wrapper will retry the system call. + """ + while True: + try: + return select.select(*args, **kwargs) + except Exception as e: + # Retry select call when EINTR + if e.args and e.args[0] == errno.EINTR: + continue + else: + raise diff --git a/prompt_toolkit/code.py b/prompt_toolkit/code.py new file mode 100644 index 000000000..bdac31d20 --- /dev/null +++ b/prompt_toolkit/code.py @@ -0,0 +1,136 @@ +""" +The `Code` object is responsible for parsing a document, received from the `Line` class. +It's usually tokenized, using a Pygments lexer. +""" +from __future__ import unicode_literals +from pygments.token import Token + +__all__ = ( + 'Code', + 'CodeBase', + 'Completion' + 'ValidationError', +) + + +class Completion(object): + def __init__(self, display='', suffix=''): # XXX: rename suffix to 'addition' + self.display = display + self.suffix = suffix + + def __repr__(self): + return 'Completion(display=%r, suffix=%r)' % (self.display, self.suffix) + + +class ValidationError(Exception): + def __init__(self, line, column, message=''): + self.line = line + self.column = column + self.message = message + + + +class CodeBase(object): + """ Dummy base class for Code implementations. + + The methods in here are methods that are expected to exist for the `Line` + and `Renderer` classes. """ + def __init__(self, document): + self.document = document + self.text = document.text + self.cursor_position = document.cursor_position + + def get_tokens(self): + return [(Token, self.text)] + + def complete(self): + """ return one `Completion` instance or None. """ + # If there is one completion, return that. + completions = list(self.get_completions(True)) + + # Return the common prefix. + return _commonprefix([ c.suffix for c in completions ]) + + def get_completions(self, recursive=False): + """ Yield `Completion` instances. """ + if False: + yield + + def validate(self): + """ + Validate the input. + If invalid, this should raise `self.validation_error`. + """ + pass + + + +class Code(CodeBase): + """ + Representation of a code document. + (Immutable class -- caches tokens) + + :attr document: :class:`~prompt_toolkit.line.Document` + """ + #: The pygments Lexer class to use. + lexer_cls = None + + def __init__(self, document): + super(Code, self).__init__(document) + self._tokens = None + + @property + def _lexer(self): + """ Return lexer instance. """ + if self.lexer_cls: + return self.lexer_cls( + stripnl=False, + stripall=False, + ensurenl=False) + else: + return None + + def get_tokens(self): + """ Return the list of tokens for the input text. """ + # This implements caching. Usually, you override `_get_tokens` + if self._tokens is None: + self._tokens = self._get_tokens() + return self._tokens + + def get_tokens_before_cursor(self): + """ Return the list of tokens that appear before the cursor. If the + cursor is in the middle of a token, that token will be split. """ + count = 0 + result = [] + for c in self.get_tokens(): + if count + len(c[1]) < self.cursor_position: + result.append(c) + count += len(c[1]) + elif count < self.cursor_position: + result.append((c[0], c[1][:self.cursor_position - count])) + break + else: + break + return result + + def _get_tokens(self): + if self._lexer: + return list(self._lexer.get_tokens(self.text)) + else: + return [(Token, self.text)] + + +def _commonprefix(strings): + # 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/prompt_toolkit/contrib/__init__.py b/prompt_toolkit/contrib/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/prompt_toolkit/contrib/repl.py b/prompt_toolkit/contrib/repl.py new file mode 100644 index 000000000..6a24c30f4 --- /dev/null +++ b/prompt_toolkit/contrib/repl.py @@ -0,0 +1,600 @@ +""" +Utility for creating a Python repl. + +:: + + from prompt_toolkit.contrib.repl import embed + embed(globals(), locals(), vi_mode=False) + +""" +from __future__ import print_function + +from pygments import highlight +from pygments.formatters.terminal256 import Terminal256Formatter +from pygments.lexers import PythonLexer, PythonTracebackLexer +from pygments.style import Style +from pygments.token import Keyword, Operator, Number, Name, Error, Comment, Token + +from prompt_toolkit import CommandLine, AbortAction +from prompt_toolkit.code import Completion, Code, ValidationError +from prompt_toolkit.enums import LineMode +from prompt_toolkit.history import FileHistory, History +from prompt_toolkit.inputstream_handler import ViInputStreamHandler, EmacsInputStreamHandler, ViMode +from prompt_toolkit.line import Exit, Line +from prompt_toolkit.prompt import Prompt + +from six import exec_ + +import jedi +import os +import re +import traceback +import threading + + +__all__ = ('PythonCommandLine', 'embed') + + +class PythonStyle(Style): + background_color = None + styles = { + Keyword: '#ee00ee', + Operator: '#aa6666', + Number: '#ff0000', + Name: '#008800', + Token.Literal.String: '#440000', + + Error: '#000000 bg:#ff8888', + Comment: '#0000dd', + Token.Bash: '#333333', + Token.IPython: '#660066', + + # Signature highlighting. + Token.Signature: '#888888', + Token.Signature.Operator: 'bold #888888', + Token.Signature.CurrentName: 'bold underline #888888', + + # Highlighting for the reverse-search prompt. + Token.Prompt: 'bold #004400', + Token.Prompt.ISearch.Bracket: 'bold #440000', + Token.Prompt.ISearch: '#550000', + Token.Prompt.ISearch.Backtick: 'bold #550033', + Token.Prompt.ISearch.Text: 'bold', + Token.Prompt.SecondLinePrefix: 'bold #888888', + Token.Prompt.ArgText: 'bold', + + Token.Toolbar: 'bg:#222222 #aaaaaa', + Token.Toolbar.Off: 'bg:#222222 #888888', + Token.Toolbar.On: 'bg:#222222 #ffffff', + Token.Toolbar.Mode: 'bg:#222222 #ffffaa', + + # Completion menu + Token.CompletionMenu.CurrentCompletion: 'bg:#dddddd #000000', + Token.CompletionMenu.Completion: 'bg:#888888 #ffff88', + Token.CompletionMenu.ProgressButton: 'bg:#000000', + Token.CompletionMenu.ProgressBar: 'bg:#aaaaaa', + + # Grayed + Token.Aborted: '#aaaaaa', + + Token.ValidationError: 'bg:#aa0000 #ffffff', + } + + +class _PythonInputStreamHandlerMixin(object): + """ + Extensions to the input stream handler for custom 'enter' behaviour. + """ + def F6(self): + """ Enable/Disable paste mode. """ + self._line.paste_mode = not self._line.paste_mode + if self._line.paste_mode: + self._line.is_multiline = True + + def F7(self): + self._line.is_multiline = not self._line.is_multiline + + def _auto_enable_multiline(self): + """ + (Temporarily) enable multiline when pressing enter. + When: + - We press [enter] after a color or backslash (line continuation). + - After unclosed brackets. + """ + def is_empty_or_space(s): + return s == '' or s.isspace() + cursor_at_the_end = self._line.document.cursor_at_the_end + + # If we just typed a colon, or still have open brackets, always insert a real newline. + if cursor_at_the_end and (self._line._colon_before_cursor() or + self._line._has_unclosed_brackets()): + self._line.is_multiline = True + + # If the character before the cursor is a backslash (line continuation + # char), insert a new line. + elif cursor_at_the_end and (self._line.document.text_before_cursor[-1:] == '\\'): + self._line.is_multiline = True + + def tab(self): + # When the 'tab' key is pressed with only whitespace character before the + # cursor, do autocompletion. Otherwise, insert indentation. + current_char = self._line.document.current_line_before_cursor + if not current_char or current_char.isspace(): + self._line.insert_text(' ') + else: + self._line.complete_next() + + def backtab(self): + if self._line.mode == LineMode.COMPLETE: + self._line.complete_previous() + + def enter(self): + self._auto_enable_multiline() + super(_PythonInputStreamHandlerMixin, self).enter() + + +class PythonViInputStreamHandler(_PythonInputStreamHandlerMixin, ViInputStreamHandler): + pass + + +class PythonEmacsInputStreamHandler(_PythonInputStreamHandlerMixin, EmacsInputStreamHandler): + pass + + +class PythonLine(Line): + """ + Custom `Line` class with some helper functions. + """ + def reset(self, *a, **kw): + super(PythonLine, self).reset(*a, **kw) + + #: Boolean `paste` flag. If True, don't insert whitespace after a + #: newline. + self.paste_mode = False + + #: Boolean `multiline` flag. If True, [Enter] will always insert a + #: newline, and it is required to use [Meta+Enter] execute commands. + self.is_multiline = False + + # Code signatures. (This is set asynchronously after a timeout.) + self.signatures = [] + + def _text_changed(self): + self.is_multiline = '\n' in self.text + + def _colon_before_cursor(self): + return self.document.text_before_cursor[-1:] == ':' + + def _has_unclosed_brackets(self): + """ Starting at the end of the string. If we find an opening bracket + for which we didn't had a closing one yet, return True. """ + text = self.document.text_before_cursor + stack = [] + + # Ignore braces inside strings + text = re.sub(r'''('[^']*'|"[^"]*")''', '', text) # XXX: handle escaped quotes.! + + for c in reversed(text): + if c in '])}': + stack.append(c) + + elif c in '[({': + if stack: + if ((c == '[' and stack[-1] == ']') or + (c == '{' and stack[-1] == '}') or + (c == '(' and stack[-1] == ')')): + stack.pop() + else: + # Opening bracket for which we didn't had a closing one. + return True + + return False + + def newline(self): + r""" + Insert \n at the cursor position. Also add necessary padding. + """ + insert_text = super(PythonLine, self).insert_text + + if self.paste_mode or self.document.current_line_after_cursor: + insert_text('\n') + else: + # Go to new line, but also add indentation. + current_line = self.document.current_line_before_cursor.rstrip() + insert_text('\n') + + # Copy whitespace from current line + for c in current_line: + if c.isspace(): + insert_text(c) + else: + break + + # If the last line ends with a colon, add four extra spaces. + if current_line[-1:] == ':': + for x in range(4): + insert_text(' ') + + def cursor_left(self): + """ + When moving the cursor left in the left indentation margin, move four + spaces at a time. + """ + before_cursor = self.document.current_line_before_cursor + + if not self.paste_mode and not self.mode == LineMode.INCREMENTAL_SEARCH and before_cursor.isspace(): + count = 1 + (len(before_cursor) - 1) % 4 + else: + count = 1 + + for i in range(count): + super(PythonLine, self).cursor_left() + + def cursor_right(self): + """ + When moving the cursor right in the left indentation margin, move four + spaces at a time. + """ + before_cursor = self.document.current_line_before_cursor + after_cursor = self.document.current_line_after_cursor + + # Count space characters, after the cursor. + after_cursor_space_count = len(after_cursor) - len(after_cursor.lstrip()) + + if (not self.paste_mode and not self.mode == LineMode.INCREMENTAL_SEARCH and + (not before_cursor or before_cursor.isspace()) and after_cursor_space_count): + count = min(4, after_cursor_space_count) + else: + count = 1 + + for i in range(count): + super(PythonLine, self).cursor_right() + + +class PythonPrompt(Prompt): + def __init__(self, line, code, pythonline): + super(PythonPrompt, self).__init__(line, code) + self._pythonline = pythonline + + @property + def _prefix(self): + return (Token.Prompt, 'In [%s]' % self._pythonline.current_statement_index) + + def get_default_prompt(self): + yield self._prefix + yield (Token.Prompt, ': ') + + def get_isearch_prompt(self): + yield self._prefix + yield (Token.Prompt, ': ') + + def get_arg_prompt(self): + yield self._prefix + yield (Token.Prompt, ': ') + + def get_help_tokens(self): + """ + When inside functions, show signature. + """ + result = [] + result.append((Token, '\n')) + + if self.line.mode == LineMode.INCREMENTAL_SEARCH: + result.extend(list(super(PythonPrompt, self).get_isearch_prompt())) + elif self.line._arg_prompt_text: + result.extend(list(super(PythonPrompt, self).get_arg_prompt())) + elif self.line.validation_error: + result.extend(self._get_error_tokens()) + else: + result.extend(self._get_signature_tokens()) + + result.extend(self._get_toolbar_tokens()) + return result + + def _get_signature_tokens(self): + result = [] + append = result.append + Signature = Token.Signature + + if self.line.signatures: + sig = self.line.signatures[0] # Always take the first one. + + append((Token, ' ')) + append((Signature, sig.full_name)) + append((Signature.Operator, '(')) + + for i, p in enumerate(sig.params): + if i == sig.index: + append((Signature.CurrentName, str(p.name))) + else: + append((Signature, str(p.name))) + append((Signature.Operator, ', ')) + + result.pop() # Pop last comma + append((Signature.Operator, ')')) + + return result + + def _get_error_tokens(self): + if self.line.validation_error: + text = '%s (line=%s column=%s)' % ( + self.line.validation_error.message, + self.line.validation_error.line + 1, + self.line.validation_error.column + 1) + return [(Token.ValidationError, text)] + else: + return [] + + def _get_toolbar_tokens(self): + result = [] + append = result.append + TB = Token.Toolbar + + append((Token, '\n ')) + append((TB, ' ')) + + # Mode + if self.line.mode == LineMode.INCREMENTAL_SEARCH: + append((TB.Mode, '(SEARCH)')) + append((TB, ' ')) + elif self._pythonline.vi_mode: + mode = self._pythonline._inputstream_handler._vi_mode + if mode == ViMode.NAVIGATION: + append((TB.Mode, '(NAV)')) + append((TB, ' ')) + elif mode == ViMode.INSERT: + append((TB.Mode, '(INSERT)')) + append((TB, ' ')) + elif mode == ViMode.REPLACE: + append((TB.Mode, '(REPLACE)')) + append((TB, ' ')) + + if self._pythonline._inputstream_handler.is_recording_macro: + append((TB.Mode, 'recording')) + append((TB, ' ')) + + else: + append((TB.Mode, '(emacs)')) + append((TB, ' ')) + + # Position in history. + result.append((TB, '%i/%i ' % (self.line._working_index + 1, len(self.line._working_lines)))) + + # Shortcuts. + if self.line.mode == LineMode.INCREMENTAL_SEARCH: + append((TB, '[Ctrl-G] Cancel search')) + else: + if self.line.paste_mode: + append((TB.On, '[F6] Paste mode (on) ')) + else: + append((TB.Off, '[F6] Paste mode (off) ')) + + if self.line.is_multiline: + append((TB.On, '[F7] Multiline (on) ')) + else: + append((TB.Off, '[F7] Multiline (off) ')) + + if self.line.is_multiline: + append((TB, '[Meta+Enter] Execute')) + else: + append((TB, ' ')) + + append((TB, ' ')) + + return result + + +class PythonCode(Code): + lexer_cls = PythonLexer + + def __init__(self, document, globals, locals): + self._globals = globals + self._locals = locals + super(PythonCode, self).__init__(document) + + def validate(self): + """ Check input for Python syntax errors. """ + try: + compile(self.text, '', 'exec') + except SyntaxError as e: + raise ValidationError(e.lineno - 1, e.offset - 1, 'Syntax Error') + + def _get_tokens(self): + """ Overwrite parent function, to change token types of non-matching + brackets to Token.Error for highlighting. """ + result = super(PythonCode, self)._get_tokens() + + stack = [] # Pointers to the result array + + for index, (token, text) in enumerate(result): + top = result[stack[-1]][1] if stack else '' + + if text in '({[]})': + if text in '({[': + # Put open bracket on the stack + stack.append(index) + + elif (text == ')' and top == '(' or + text == '}' and top == '{' or + text == ']' and top == '['): + # Match found + stack.pop() + else: + # No match for closing bracket. + result[index] = (Token.Error, text) + + # Highlight unclosed tags that are still on the stack. + for index in stack: + result[index] = (Token.Error, result[index][1]) + + return result + + def _get_jedi_script(self): + try: + return jedi.Interpreter(self.text, + column=self.document.cursor_position_col, + line=self.document.cursor_position_row + 1, + path='input-text', + namespaces=[ self._locals, self._globals ]) + + except jedi.common.MultiLevelStopIteration: + # This happens when the document is just a backslash. + return None + except ValueError: + # Invalid cursor position. + # ValueError('`column` parameter is not in a valid range.') + return None + + def get_completions(self, recursive=False): + """ Ask jedi to complete. """ + script = self._get_jedi_script() + + if script: + for c in script.completions(): + yield Completion(c.name, c.complete) + + +class PythonCommandLine(CommandLine): + line_cls = PythonLine + + enable_concurency = True + + def history_cls(self): + if self.history_filename: + return FileHistory(self.history_filename) + else: + return History() + + @property + def inputstream_handler_cls(self): + if self.vi_mode: + return PythonViInputStreamHandler + else: + return PythonEmacsInputStreamHandler + + def __init__(self, globals=None, locals=None, vi_mode=False, stdin=None, stdout=None, history_filename=None, style_cls=PythonStyle): + self.globals = globals or {} + self.globals.update({ k: getattr(__builtins__, k) for k in dir(__builtins__) }) + self.locals = locals or {} + self.history_filename = history_filename + self.style_cls = style_cls + + self.vi_mode = vi_mode + self.get_signatures_thread_running = False + + #: Incremeting integer counting the current statement. + self.current_statement_index = 1 + + # The `PythonCode` needs a reference back to this class in order to do + # autocompletion on the globals/locals. + self.code_cls = lambda document: PythonCode(document, self.globals, self.locals) + + # The `PythonPrompt` class needs a reference back in order to show the + # input method. + self.prompt_cls = lambda line, code: PythonPrompt(line, code, self) + + super(PythonCommandLine, self).__init__(stdin=stdin, stdout=stdout) + + def on_input_timeout(self, code_obj): + """ + When there is no input activity, + in another thread, get the signature of the current code. + """ + # Never run multiple get-signature threads. + if self.get_signatures_thread_running: + return + self.get_signatures_thread_running = True + + class GetSignatureThread(threading.Thread): + def run(t): + script = code_obj._get_jedi_script() + + # Show signatures in help text. + if script: + try: + signatures = script.call_signatures() + except ValueError: + # e.g. in case of an invalid \x escape. + signatures = [] + else: + signatures = [] + + self.get_signatures_thread_running = False + + # Set signatures and redraw if the text didn't change in the + # meantime. Otherwise request new signatures. + if self._line.text == code_obj.text: + self._line.signatures = signatures + self.request_redraw() + else: + self.on_input_timeout(self._line.create_code_obj()) + + GetSignatureThread().start() + + def start_repl(self): + """ + Start the Read-Eval-Print Loop. + """ + try: + while True: + # Read + document = self.read_input( + on_abort=AbortAction.RETRY, + on_exit=AbortAction.RAISE_EXCEPTION) + line = document.text + + if line and not line.isspace(): + try: + # Eval and print. + self._execute(line) + except KeyboardInterrupt as e: # KeyboardInterrupt doesn't inherit from Exception. + self._handle_keyboard_interrupt(e) + except Exception as e: + self._handle_exception(e) + + self.current_statement_index += 1 + except Exit: + pass + + def _execute(self, line): + """ + Evaluate the line and print the result. + """ + if line[0:1] == '!': + # Run as shell command + os.system(line[1:]) + else: + # Try eval first + try: + result = eval(line, self.globals, self.locals) + self.locals['_'] = self.locals['_%i' % self.current_statement_index] = result + if result is not None: + print('Out[%i]: %r' % (self.current_statement_index, result)) + # If not a valid `eval` expression, run using `exec` instead. + except SyntaxError: + exec_(line, self.globals, self.locals) + + print() + + def _handle_exception(self, e): + tb = traceback.format_exc() + print(highlight(tb, PythonTracebackLexer(), Terminal256Formatter())) + print(e) + + def _handle_keyboard_interrupt(self, e): + print('\rKeyboardInterrupt') + + +def embed(globals=None, locals=None, vi_mode=False, history_filename=None, no_colors=False): + """ + Call this to embed Python shell at the current point in your program. + It's similar to `IPython.embed` and `bpython.embed`. :: + + from prompt_toolkit.contrib.repl import embed + embed(globals(), locals(), vi_mode=False) + + :param vi_mode: Boolean. Use Vi instead of Emacs key bindings. + """ + cli = PythonCommandLine(globals, locals, vi_mode=vi_mode, history_filename=history_filename, + style_cls=(None if no_colors else PythonStyle)) + cli.start_repl() diff --git a/prompt_toolkit/contrib/shell/__init__.py b/prompt_toolkit/contrib/shell/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/prompt_toolkit/contrib/shell/code.py b/prompt_toolkit/contrib/shell/code.py new file mode 100644 index 000000000..22ce4e853 --- /dev/null +++ b/prompt_toolkit/contrib/shell/code.py @@ -0,0 +1,73 @@ +from __future__ import unicode_literals + +from pygments.token import Token +from prompt_toolkit.code import Code, Completion + +from .rules import Sequence, Literal, TokenStream +from .lexer import TextToken, ParametersLexer + + +# TODO: pressing enter when the last token is in a quote should insert newline. + + +class ShellCode(Code): + rule = Sequence([ Literal('Hello'), Literal('World') ]) + + def _get_tokens(self): + return list(ParametersLexer(stripnl=False, stripall=False, ensurenl=False).get_tokens(self.text)) + + def _get_lex_result(self, only_before_cursor=False): + # Take Text tokens before cursor + if only_before_cursor: + tokens = self.get_tokens_before_cursor() + else: + tokens = self.get_tokens() + parts = [ t[1] for t in tokens if t[0] in Token.Text ] + + # Separete the last token (where we are currently one) + starting_new_token = not tokens or tokens[-1][0] in Token.WhiteSpace + if starting_new_token: + last_part = '' + else: + last_part = parts.pop() + + # Unescape tokens + parts = [ TextToken(t).unescaped_text for t in parts ] + last_part_token = TextToken(last_part) + + return parts, last_part_token + + + def get_completions(self, recursive=False): + parts, last_part_token = self._get_lex_result(only_before_cursor=True) + + def wrap_completion(c): + # TODO: extend 'Completion' class to contain flag whether + # the completion is 'complete', so whether we should + # add a space. + if last_part_token.inside_double_quotes: + return Completion(c.display, c.suffix + '" ') + + elif last_part_token.inside_single_quotes: + return Completion(c.display, c.suffix + "' ") + else: + return Completion(c.display, c.suffix + " ") + + # Parse grammar + stream = TokenStream(parts) + + # For any possible parse tre + for tree in self.rule.parse(stream): + for c in tree.complete(last_part_token.unescaped_text): + yield wrap_completion(c) + + def get_parse_info(self): + parts, last_part_token = self._get_lex_result() + stream = TokenStream(parts + [ last_part_token.unescaped_text ]) # TODO: raise error when this last token is not finished. + + trees = list(self.rule.parse(stream)) + + if len(trees) == 1: + return(trees[0]) + else: + raise Exception('Invalid command.') # TODO: raise better exception. diff --git a/prompt_toolkit/contrib/shell/completers.py b/prompt_toolkit/contrib/shell/completers.py new file mode 100644 index 000000000..a9897f8ad --- /dev/null +++ b/prompt_toolkit/contrib/shell/completers.py @@ -0,0 +1,28 @@ +import os + +from prompt_toolkit.code import Completion + +class Path(object): + """ + Complete for Path variables. + """ + _include_files = True + + def complete(self, text): + try: + directory = os.path.dirname(text) or '.' + prefix = os.path.basename(text) + + for filename in os.listdir(directory): + if filename.startswith(prefix): + completion = filename[len(prefix):] + + if os.path.isdir(os.path.join(directory, filename)): + completion += '/' + else: + if not self._include_files: + continue + + yield Completion(filename, completion) + except OSError: + pass diff --git a/prompt_toolkit/contrib/shell/lexer.py b/prompt_toolkit/contrib/shell/lexer.py new file mode 100644 index 000000000..9fbf0bd3e --- /dev/null +++ b/prompt_toolkit/contrib/shell/lexer.py @@ -0,0 +1,132 @@ +""" +Lexer for any shell input line. +""" +from pygments.lexer import RegexLexer +from pygments.token import Token + +import re + +__all__ = ('ParametersLexer', 'TextToken') + +class ParametersLexer(RegexLexer): + flags = re.DOTALL | re.MULTILINE | re.VERBOSE + + tokens = { + 'root': [ + (r'\s+', Token.WhiteSpace), + (r''' + ( + # Part of string inside a double quote. + "([^"\\]|\\.)*("|$) + | + + # Part of string inside a single quote. + # (no escaping in single quotes.) + '[^']*('|$) + | + + # Escaped character outside quotes + \\(.|$) + | + + # Any not-quote character. + [^'"\ \\]+ + )+ + ''', Token.Text), + (r'.', Token.Error), # Can not happen normally. + ] + } + + +class TextToken(object): + """ + Takes a 'Text' token from the lexer and unescapes it. + """ + def __init__(self, text): + unescaped_text = [] + + #: Indicate that the input text has a backslash at the end. + trailing_backslash = False + + #: Indicates that we have unclosed double quotes + inside_double_quotes = False + + #: Indicates that we have unclosed single quotes + inside_single_quotes = False + + # Unescape it + i = 0 + get = lambda: text[i] + while i < len(text): + # Inside double quotes. + if get() == '"': + i += 1 + + while i < len(text): + if get() == '\\': + i += 1 + if i < len(text): + unescaped_text.append(get()) + i += 1 + else: + inside_double_quotes = True + trailing_backslash = True + break + elif get() == '"': + i += 1 + break + else: + unescaped_text.append(get()) + i += 1 + else: + # (while-loop failed without a break.) + inside_double_quotes = True + + # Inside single quotes. + elif get() == "'": + i += 1 + + while i < len(text): + if get() == "'": + i += 1 + break + else: + unescaped_text.append(get()) + i += 1 + else: + inside_single_quotes = True + + # Backslash outside quotes. + elif text[i] == "\\": + i += 1 + + if i < len(text): + unescaped_text.append(get()) + i += 1 + else: + trailing_backslash = True + + # Any other character. + else: + unescaped_text.append(get()) + i += 1 + + + self.unescaped_text = u''.join(unescaped_text) + self.trailing_backslash = trailing_backslash + self.inside_double_quotes = inside_double_quotes + self.inside_single_quotes = inside_single_quotes + + def transform_appended_text(self, text): + if self.inside_single_quotes: + text = text.replace("'", r"\'") + text += "'" + + if self.inside_double_quotes: + text = text.replace('"', r'\"') + text += '"' + + # TODO: handle trailing backslash + return text + + diff --git a/prompt_toolkit/contrib/shell/nodes.py b/prompt_toolkit/contrib/shell/nodes.py new file mode 100644 index 000000000..a932350fb --- /dev/null +++ b/prompt_toolkit/contrib/shell/nodes.py @@ -0,0 +1,249 @@ +""" +Nodes for representing the parse tree. + +The return type for `.rule.Rule.parse`. +""" +from pygments.token import Token + +__all__ = ('AnyNode', 'LiteralNode', 'RepeatNode', 'SequenceNode', 'VariableNode') + + +class ParseNode(object): + def __init__(self, rule): + self.rule = rule + + @property + def is_complete(self): + """ + Boolean, indicating that we have a gramatical match; all the + variables/literals are filled in. + """ + return True + + @property + def is_extendible(self): + """ + Boolean, indicating whether this node could consume any more tokens. + In case of repeats for instance, a node can keep consuming tokens. + """ + return not self.is_complete + + def complete(self, text=''): + """ + Given the beginning text of the *following* token, yield a list of + `Completion` instances. + """ + if False: + yield + + def get_help_tokens(self): + """ + Only yield a help part if this node didn't contain any text yet. + """ + if not self._text: + for t in self.rule.get_help_tokens(): + yield t + + def get_variables(self): + """ + Get a dictionary that contains all the variables (`dest` in the grammar + tree definition.). + """ + if self.rule.dest: + return { self.rule.dest: True } + else: + return { } + + +class EmptyNode(ParseNode): + """ + When the inputstream is empty, but input tokens are required, this node is + a placeholder for expected input. + """ + def __repr__(self): + return 'EmptyNode(%r)' % self.rule + + # This node is obviously not complete, as we lack an input token. + is_complete = False + + def complete(self, text=''): + for c in self.rule.complete(text): + yield c + + def get_help_tokens(self): + for k in self.rule.get_help_tokens(): + yield k + + +class SequenceNode(ParseNode): + """ Parse tree result of sequence """ + def __init__(self, rule, children): + super(SequenceNode, self).__init__(rule) + self.children = children + + def __repr__(self): + return 'SequenceNode(%r)' % self.children + + @property + def is_complete(self): + return len(self.children) == len(self.rule.rules) and all(d.is_complete for d in self.children) + + @property + def is_extendible(self): + """ This node can be extended as long as it's incomplete, or the last + child is extendible (repeatable). """ + return not self.is_complete or (self.children and self.children[-1].is_extendible) + + def complete(self, text=''): + # When the last child node is unfinished, complete that. + # (e.g. nested Sequence, only containing a few tokens.) + if self.children and not self.children[-1].is_complete: + for completion in self.children[-1].complete(text): + yield completion + + # Every child in this sequence is 'complete.' + else: + # Complete using the first following rule. + if len(self.children) < len(self.rule.rules): + for completion in self.rule.rules[len(self.children)].complete(text): + yield completion + + # If the last child allows repetitions (Nested repeat.) + if self.children and self.children[-1].is_extendible: + for completion in self.children[-1].complete(text): + yield completion + + def get_help_tokens(self): + first = True + if self.children and self.children[-1].is_extendible: + for k in self.children[-1].get_help_tokens(): + yield k + first = False + + for rule in self.rule.rules[len(self.children):]: + if not first: + yield (Token, ' ') + first = False + + for k in rule.get_help_tokens(): + yield k + + def get_variables(self): + result = super(SequenceNode, self).get_variables() + for c in self.children: + result.update(c.get_variables()) + return result + + +class RepeatNode(ParseNode): + def __init__(self, rule, children, tokens_after): + super(RepeatNode, self).__init__(rule) + self.children = children + + #: True if there were input tokens following this tree. + self._tokens_after = tokens_after + + def __repr__(self): + return 'RepeatNode(%r)' % self.children + + @property + def is_complete(self): # TODO: revise the definition of 'is_complete'... (does it mean not showing help info or processable?) + # Note that an empty repeat is also 'complete' + return all(c.is_complete for c in self.children) + + @property + def is_extendible(self): + return True + + def complete(self, text=''): + if self.children and not self.children[-1].is_complete: + for c in self.children[-1].complete(text): + yield c + else: + for c in self.rule.complete(text): + yield c + + def get_help_tokens(self): + # If in the original input, there were tokens following the repeat, then + # we can consider this node complete. + if self._tokens_after: + pass + + # If we don't have children yet, take the help of the nested grammar itself. + elif not self.children or self.is_complete: + for t in self.rule.get_help_tokens(): + yield t + + else: + for k in self.children[-1].get_help_tokens(): + yield k + + def get_variables(self): + result = super(RepeatNode, self).get_variables() + for c in self.children: + result.update(c.get_variables()) + return result + + +class AnyNode(ParseNode): + def __init__(self, rule, child): + assert isinstance(child, ParseNode) + + super(AnyNode, self).__init__(rule) + self.child = child + + def __repr__(self): + return 'AnyNode(%r)' % self.child + + @property + def is_complete(self): + return self.child.is_complete + + @property + def is_extendible(self): + return self.child.is_extendible + + def complete(self, text=''): + for completion in self.child.complete(text): + yield completion + + def get_help_tokens(self): + for t in self.child.get_help_tokens(): + yield t + + def get_variables(self): + result = super(AnyNode, self).get_variables() + result.update(self.child.get_variables()) + return result + + +class LiteralNode(ParseNode): + def __init__(self, rule, text): + #assert isinstance(rule, Literal) + super(LiteralNode, self).__init__(rule) + self._text = text + + def __repr__(self): + return 'LiteralNode(%r)' % self._text + + def get_variables(self): + if self.rule.dest: + return { self.rule.dest: self._text } + else: + return { } + + +class VariableNode(ParseNode): + def __init__(self, rule, text): + #assert isinstance(rule, Variable) + super(VariableNode, self).__init__(rule) + self._text = text + + def __repr__(self): + return 'VariableNode(%r)' % self._text + + def get_variables(self): + if self.rule.dest: + return { self.rule.dest: self._text } + else: + return { } diff --git a/prompt_toolkit/contrib/shell/prompt.py b/prompt_toolkit/contrib/shell/prompt.py new file mode 100644 index 000000000..c7c49a6a5 --- /dev/null +++ b/prompt_toolkit/contrib/shell/prompt.py @@ -0,0 +1,34 @@ +from prompt_toolkit.prompt import Prompt +from pygments.token import Token + +from .rules import TokenStream + + +class ShellPrompt(Prompt): + def get_help_tokens(self): + parts, last_part_token = self.code._get_lex_result() + + # Don't show help when you're in the middle of typing a 'token'. + # (Show after having typed the space, or at the start of the line.) + if not last_part_token.unescaped_text: + # Parse grammar + stream = TokenStream(parts) + trees = list(self.code.rule.parse(stream)) + + # print (trees) ### debug + + if len(trees) > 1: + yield (Token.Placeholder.Bracket, '[') + + first = True + + for tree in trees: + if not first: + yield (Token.Placeholder.Separator, '|') + first = False + + for t in tree.get_help_tokens(): + yield t + + if len(trees) > 1: + yield (Token.Placeholder.Bracket, ']') diff --git a/prompt_toolkit/contrib/shell/rules.py b/prompt_toolkit/contrib/shell/rules.py new file mode 100644 index 000000000..a7185a758 --- /dev/null +++ b/prompt_toolkit/contrib/shell/rules.py @@ -0,0 +1,375 @@ +""" +Rules for representing the grammar of a shell command line. + +The grammar is defined as a regular langaage, the alphabet consists tokens +delivered by the lexer. Learn more about regular languages on Wikipedia: +http://en.wikipedia.org/wiki/Regular_language + +We have the following constructs for defining the language. + + - Literal: + Defines a Singleton language, matches one token. + + - Sequence: + Defines a language that is a concatenation of others. + + - Any: + Union operation. + + +Example: + +:: + + # Define the grammar + grammar = Any([ + Sequence([ Literal('Hello'), Literal('World') ]), + Sequence([ Literal('Something'), Literal('Else') ]), + ]) + + # Create a stream of the input text. + stream = TokenStream(['Hello', 'World']) + + # Call parser (This yields all the possible parse trees. -- as long as the + # input document is not complete, there can be several incomplete possible + # parse trees. -- The grammar can be ambiguous in that case.) + for tree in grammar.parse(stream): + print(tree) + +""" +from pygments.token import Token + +import six +import re + +from .nodes import AnyNode, SequenceNode, RepeatNode, LiteralNode, VariableNode, EmptyNode +from prompt_toolkit.code import Completion + +__all__ = ( + # Classes for defining the grammar. + 'Any', + 'Literal', + 'Repeat', + 'Sequence', + 'Variable' + + # Wrapper around the input tokens. + 'TokenStream', +) + + +class Rule(object): + """ + Abstract base class for any rule. + + A rule represents the grammar of a regular language. + """ + placeholder_token_class = Token.Placeholder + + def __init__(self, placeholder='', dest=None): + self.placeholder = placeholder + self.dest = dest + + @property + def matches_empty_input(self): + """ + Boolean indicating that calling parse with an empty input would yield a + valid parse tree matching the grammar. + """ + return False + + def parse(self, lexer_stream, allow_incomplete_trees=True): + # TODO: implement option: allow_incomplete_trees=True + # TODO: OR REMOVE THIS OPTION (I don't think we still need it.) + """ + Yield a list of possible parse trees. + + We avoid yielding too many trees. + But suppose our grammar is: + ( "a" "b" "c" ) | ( "a" "b" "c" ) + and our input is: + "a" "b" + then we can yield both options. + There is no obvious way to yield them as a single incomplete parse + tree, because the parser is in an obviously different state. + """ + if False: + yield + + def get_help_tokens(self): + """ + Generate help text for this rule. + (Yieds (Token, text) tuples.) + """ + yield (self.placeholder_token_class, self.placeholder) + + +class Sequence(Rule): + """ + Concatenates several other rules. + """ + def __init__(self, rules, placeholder=None, dest=None): + assert isinstance(rules, list) + + super(Sequence, self).__init__(placeholder, dest) + self.rules = rules + + def __repr__(self): + return 'Sequence(rules=%r)' % self.rules + + @property + def matches_empty_input(self): + return all(r.matches_empty_input for r in self.rules) + + def parse(self, lexer_stream): + def _parse(rules): + if rules and (rules[0].matches_empty_input or lexer_stream.has_more_tokens): + for tree in rules[0].parse(lexer_stream): + with lexer_stream.restore_point: + for suffix in _parse(rules[1:]): + yield [ tree ] + suffix + else: + yield [] + + if lexer_stream.has_more_tokens: + for lst in _parse(self.rules): + yield SequenceNode(self, lst) + else: + yield EmptyNode(self) + + def get_help_tokens(self): + first = True + for c in self.rules: + if not first: + yield (Token, ' ') + first = False + + for k in c.get_help_tokens(): + yield k + + def complete(self, text=''): + for completion in self.rules[0].complete(text): + yield completion + + +class Any(Rule): + """ + Union of several other rules. + + If the input matches any of the rules, the input matches the defined + grammar. + """ + def __init__(self, rules, placeholder=None, dest=None): + assert len(rules) >= 1 + + super(Any, self).__init__(placeholder, dest) + self.rules = rules + + def __repr__(self): + return 'Any(rules=%r)' % self.rules + + @property + def matches_empty_input(self): + return any(r.matches_empty_input for r in self.rules) + + def parse(self, lexer_stream): + if lexer_stream.has_more_tokens: + for rule in self.rules: + with lexer_stream.restore_point: + for result in rule.parse(lexer_stream): + yield AnyNode(self, result) + else: + yield EmptyNode(self) + + def complete(self, text=''): + for r in self.rules: + for c in r.complete(text): + yield c + + def get_help_tokens(self): + # The help text is a concatenation of the available options, surrounded + # by brackets. + if len(self.rules) > 1: + yield (Token.Placeholder.Bracket, '[') + + first = True + for r in self.rules: + if not first: + yield (Token.Placeholder.Separator, '|') + + for t in r.get_help_tokens(): + yield t + first = False + + if len(self.rules) > 1: + yield (Token.Placeholder.Bracket, ']') + + +class Repeat(Rule): + """ + Allow the input to be a repetition of the given grammar. + (The empty input is a valid match here.) + + The "Kleene star" operation of another rule. + http://en.wikipedia.org/wiki/Kleene_star + """ + def __init__(self, rule, placeholder=None, dest=None): + super(Repeat, self).__init__(placeholder, dest) + self.rule = rule + + # Don't allow empty rules inside a Repeat clause. + # (This causes eternal recursion in the parser.) + # If this happens, there should be a much better way to define the grammar. + if rule.matches_empty_input: + raise Exception('Rule %r can not be nested inside Repeat because it matches the empty input.' % rule) + + @property + def matches_empty_input(self): + """ (An empty input is always a valid match for a repeat rule.) """ + return True + + def parse(self, lexer_stream): + def _parse(): + found = False + + if lexer_stream.has_more_tokens: + for tree in self.rule.parse(lexer_stream): + with lexer_stream.restore_point: + for suffix in _parse(): + yield [ tree ] + suffix + found = True + + if not found: + yield [] + + if lexer_stream.has_more_tokens: + for lst in _parse(): + yield RepeatNode(self, lst, lexer_stream.has_more_tokens) + else: + yield RepeatNode(self, [], False) + + def complete(self, text=''): + for c in self.rule.complete(text): + yield c + + def get_help_tokens(self): + for t in self.rule.get_help_tokens(): + yield t + + yield (Token.Placeholder, '...') + + +class Literal(Rule): + """ + Represents a language consisting of one token, the given string. + """ + def __init__(self, text, dest=None): + assert isinstance(text, six.string_types) + super(Literal, self).__init__(text, dest) + + self.text = text + + def __repr__(self): + return 'Literal(text=%r)' % self.text + + def parse(self, lexer_stream): + if lexer_stream.has_more_tokens: + text = lexer_stream.pop() + + if text == self.text: + yield LiteralNode(self, text) + else: + yield EmptyNode(self) + + def complete(self, text=''): + if self.text.startswith(text): + yield Completion(self.text, self.text[len(text):]) + + +class Variable(Rule): + """ + Represents a language consisting of one token, the given variable. + """ + placeholder_token_class = Token.Placeholder.Variable + + def __init__(self, completer=None, regex=None, placeholder=None, dest=None): + super(Variable, self).__init__(placeholder, dest) + + self._completer = completer() if completer else None + self._regex = re.compile(regex) if regex else None + + def __repr__(self): + return 'Variable(completer=%r)' % self._completer + + def parse(self, lexer_stream): + if lexer_stream.has_more_tokens: + # TODO: only yield if the text matches the regex. + text = lexer_stream.pop() + yield VariableNode(self, text) + else: + yield EmptyNode(self) + + def complete(self, text=''): + if self._completer: + for c in self._completer.complete(text): + yield c + + + +''' +TODO: implement Optional as follows: + +def Optional(rule): + """ Optional is an 'Any' between the empty sequence or the actual rule. """ + return Any([ + Sequence([]), + rule + ]) +''' + + + +class TokenStream(object): + """ + Wraps a stream of the input tokens. + (Usually, this are the unescaped tokens, received from the lexer.) + + This input stream implements the push/pop stack required for the + backtracking for parsing the regular grammar. + """ + def __init__(self, tokens=None): + self._tokens = (tokens or [])[::-1] + + @property + def has_more_tokens(self): + """ True when we have more tokens. """ + return len(self._tokens) > 0 + + @property + def first_token(self): + if self._tokens: + return self._tokens[-1] + + def pop(self): + """ Pop first token from the stack. """ + return self._tokens.pop() + + def __repr__(self): + return 'TokenStream(tokens=%r)' % self._tokens + + @property + def restore_point(self): + """ + Create restore point using a context manager. + + This will save the current state and restore it after the + context manager is quit. + """ + class RestoreContext(object): + def __enter__(c): + c._tokens = self._tokens[:] + + def __exit__(c, *a): + self._tokens = c._tokens + + return RestoreContext() diff --git a/prompt_toolkit/contrib/shortcuts.py b/prompt_toolkit/contrib/shortcuts.py new file mode 100644 index 000000000..2e743dc86 --- /dev/null +++ b/prompt_toolkit/contrib/shortcuts.py @@ -0,0 +1,38 @@ +""" +Useful shortcuts. +""" +from __future__ import unicode_literals + +from .. import CommandLine, AbortAction +from ..prompt import Prompt +from ..inputstream_handler import EmacsInputStreamHandler +from ..line import Line + + +def get_input(message, raise_exception_on_abort=False, multiline=False): + """ + Replacement for `raw_input`. + Ask for input, return the answer. + This returns `None` when Ctrl-D was pressed. + """ + class CustomPrompt(Prompt): + default_prompt_text = message + + + class CustomLine(Line): + is_multiline = multiline + + + class CLI(CommandLine): + prompt_cls = CustomPrompt + inputstream_handler_cls = EmacsInputStreamHandler + line_cls = CustomLine + + + cli = CLI() + + on_abort = AbortAction.RAISE_EXCEPTION if raise_exception_on_abort else AbortAction.RETURN_NONE + code_obj = cli.read_input(on_abort=on_abort, on_exit=AbortAction.IGNORE) + + if code_obj: + return code_obj.text diff --git a/prompt_toolkit/document.py b/prompt_toolkit/document.py new file mode 100644 index 000000000..2c610daa7 --- /dev/null +++ b/prompt_toolkit/document.py @@ -0,0 +1,297 @@ +""" +""" +from __future__ import unicode_literals + +import re + +__all__ = ('Document',) + + +# Regex for finding the "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.) +_FIND_WORD_RE = re.compile('([a-zA-Z0-9_]+|[^a-zA-Z0-9_\s]+)') + + +class Document(object): + """ + 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.line.Line` + object, and accessed as the `document` property of that class. + + :param text: string + :param cursor_position: int + """ + __slots__ = ('text', 'cursor_position') + + def __init__(self, text='', cursor_position=0): + self.text = text + self.cursor_position = cursor_position + + @property + def current_char(self): + """ Return character under cursor, or None """ + return self._get_char_relative_to_cursor(0) + + @property + def char_before_cursor(self): + """ Return character before the cursor, or None """ + return self._get_char_relative_to_cursor(-1) + + @property + def text_before_cursor(self): + return self.text[:self.cursor_position:] + + @property + def text_after_cursor(self): + return self.text[self.cursor_position:] + + @property + def current_line_before_cursor(self): + """ Text from the start of the line until the cursor. """ + return self.text_before_cursor.split('\n')[-1] + + @property + def current_line_after_cursor(self): + """ Text from the cursor until the end of the line. """ + return self.text_after_cursor.split('\n')[0] + + @property + def lines(self): + """ + Array of all the lines. + """ + return self.text.split('\n') + + @property + def lines_from_current(self): + """ + 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): + """ 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): + """ 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): + """ 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=0): + """ Return character relative to cursor position, or None """ + try: + return self.text[self.cursor_position + offset] + except IndexError: + return None + + @property + def cursor_position_row(self): + """ + Current row. (0-based.) + """ + return len(self.text_before_cursor.split('\n')) - 1 + + @property + def cursor_position_col(self): + """ + Current column. (0-based.) + """ + return len(self.current_line_before_cursor) + + def translate_index_to_position(self, index): + """ + Given an index for the text, return the corresponding (row, col) tuple. + """ + text_before_position = self.text[:index] + + row = len(text_before_position.split('\n')) + col = len(text_before_position.split('\n')[-1]) + + return row, col + + def translate_row_col_to_index(self, row, col): # TODO: unit test + """ + Given a (row, col) tuple, return the corresponding index. + (Row and col params are 0-based.) + """ + return len('\n'.join(self.lines[:row])) + len('\n') + col + + @property + def cursor_up_position(self): + """ + Return the cursor position (character index) where we would be if the + user pressed the arrow-up button. + """ + if '\n' in self.text_before_cursor: + lines = self.text_before_cursor.split('\n') + current_line = lines[-1] # before the cursor + previous_line = lines[-2] + + # When the current line is longer then the previous, move to the + # last character of the previous line. + if len(current_line) > len(previous_line): + return self.cursor_position - len(current_line) - 1 + + # Otherwise find the corresponding position in the previous line. + else: + return self.cursor_position - len(previous_line) - 1 + + @property + def cursor_down_position(self): + """ + Return the cursor position (character index) where we would be if the + user pressed the arrow-down button. + """ + if '\n' in self.text_after_cursor: + pos = len(self.text_before_cursor.split('\n')[-1]) + lines = self.text_after_cursor.split('\n') + current_line = lines[0] # after the cursor + next_line = lines[1] + + # When the current line is longer then the previous, move to the + # last character of the next line. + if pos > len(next_line): + return self.cursor_position + len(current_line) + len(next_line) + 1 + + # Otherwise find the corresponding position in the next line. + else: + return self.cursor_position + len(current_line) + pos + 1 + + @property + def cursor_at_the_end(self): + """ True when the cursor is at the end of the text. """ + return self.cursor_position == len(self.text) + + @property + def cursor_at_the_end_of_line(self): + """ True when the cursor is at the end of this line. """ + return self.cursor_position_col == len(self.current_line) + + def has_match_at_current_position(self, sub): + """ + `True` when this substring is found at the cursor position. + """ + return self.text[self.cursor_position:].find(sub) == 0 + + def find(self, sub, in_current_line=False, include_current_position=False): + """ + Find `text` after the cursor, return position relative to the cursor + position. Return `None` if nothing was found. + """ + if in_current_line: + text = self.current_line_after_cursor + else: + text = self.text_after_cursor + + if not include_current_position: + text = text[1:] + + index = text.find(sub) + + if index >= 0: + if not include_current_position: + index += 1 + return index + + def find_backwards(self, sub, in_current_line=False): + """ + Find `text` before the cursor, return position relative to the cursor + position. Return `None` if nothing was found. + """ + if in_current_line: + before_cursor = self.current_line_before_cursor + else: + before_cursor = self.text_before_cursor + + index = before_cursor.rfind(sub) + + if index >= 0: + return index - len(before_cursor) + + def find_start_of_previous_word(self): + """ + Return an index relative to the cursor position pointing to the start + of the previous word. Return `None` if nothing was found. + """ + # Reverse the text before the cursor, in order to do an efficient + # backwards search. + text_before_cursor = self.text_before_cursor[::-1] + + match = _FIND_WORD_RE.search(text_before_cursor) + + if match: + return - match.end(1) + + def find_next_word_beginning(self): + """ + Return an index relative to the cursor position pointing to the start + of the next word. Return `None` if nothing was found. + """ + iterable = _FIND_WORD_RE.finditer(self.text_after_cursor) + + try: + # Take first match, unless it's the word on which we're right now. + result = next(iterable).start(1) + + if result > 0: + return result + else: + return next(iterable).start(1) + + except StopIteration: + pass + + def find_next_word_ending(self, include_current_position=False): + """ + Return an index relative to the cursor position pointing to the end + of the next word. Return `None` if nothing was found. + """ + if include_current_position: + text = self.text_after_cursor + else: + text = self.text_after_cursor[1:] + + iterable = _FIND_WORD_RE.finditer(text) + + try: + value = next(iterable).end(1) + + if include_current_position: + return value + else: + return value + 1 + + except StopIteration: + pass + + def find_next_matching_line(self, match_func): # TODO: unittest. + """ + Look downwards for empty lines. + Return the line index, relative to the current line. + """ + for index, line in enumerate(self.lines[self.cursor_position_row + 1:]): + if match_func(line): + return 1 + index + + def find_previous_matching_line(self, match_func): # TODO: unittest. + """ + Look upwards for empty lines. + Return the line index, relative to the current line. + """ + for index, line in enumerate(self.lines[:self.cursor_position_row][::-1]): + if match_func(line): + return -1 - index diff --git a/prompt_toolkit/enums.py b/prompt_toolkit/enums.py new file mode 100644 index 000000000..e5243b9f7 --- /dev/null +++ b/prompt_toolkit/enums.py @@ -0,0 +1,15 @@ +from __future__ import unicode_literals + +class LineMode(object): + """ + State of the `Line` object. + """ + NORMAL = 'normal' + INCREMENTAL_SEARCH = 'incremental-search' + COMPLETE = 'complete' + + +class IncrementalSearchDirection: + FORWARD = 'forward' + BACKWARD = 'backward' + diff --git a/prompt_toolkit/history.py b/prompt_toolkit/history.py new file mode 100644 index 000000000..c9d6defbb --- /dev/null +++ b/prompt_toolkit/history.py @@ -0,0 +1,66 @@ +from __future__ import unicode_literals + +import datetime +import os + +__all__ = ('History', 'FileHistory') + + +class History(object): + def __init__(self): + self.strings = [] + + def append(self, string): + self.strings.append(string) + + def __getitem__(self, key): + return self.strings[key] + + def __len__(self): + return len(self.strings) + + + +class FileHistory(History): + def __init__(self, filename): + super(FileHistory, self).__init__() + self.filename = filename + + self._load() + + def _load(self): + lines = [] + + def add(): + if lines: + # Join and drop trailing newline. + string = ''.join(lines)[:-1] + + self.strings.append(string) + + if os.path.exists(self.filename): + with open(self.filename, 'rb') as f: + for line in f: + line = line.decode('utf-8') + + if line.startswith('+'): + lines.append(line[1:]) + else: + add() + lines = [] + + add() + + + def append(self, string): + super(FileHistory, self).append(string) + + # Save to file. + with open(self.filename, 'ab') as f: + write = lambda t: 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/prompt_toolkit/inputstream.py b/prompt_toolkit/inputstream.py new file mode 100644 index 000000000..3d036ba57 --- /dev/null +++ b/prompt_toolkit/inputstream.py @@ -0,0 +1,180 @@ +""" +Parser for VT100 input stream. +""" +from __future__ import unicode_literals +import six + +__all__ = ('InputStream', ) + + +class InputStream(object): + """ + Parser for VT100 input stream. + + Feed the data through the `feed` method and the correct callbacks of the + `inputstream_handler` will be called. + + :: + + h = InputStreamHandler() + i = InputStream(h) + i.feed('data\x01...') + + :attr handler: :class:`~prompt_toolkit.inputstream_handler.InputStreamHandler` instance. + """ + # Lookup table of ANSI escape sequences for a VT100 terminal + CALLBACKS = { + '\x00': 'ctrl_space', # Control-Space (Also for Ctrl-@) + '\x01': 'ctrl_a', # Control-A (home) + '\x02': 'ctrl_b', # Control-B (emacs cursor left) + '\x03': 'ctrl_c', # Control-C (interrupt) + '\x04': 'ctrl_d', # Control-D (exit) + '\x05': 'ctrl_e', # Contrel-E (end) + '\x06': 'ctrl_f', # Control-F (cursor forward) + '\x07': 'ctrl_g', # Control-G + '\x08': 'ctrl_h', # Control-H (8) (Identical to '\b') + '\x09': 'ctrl_i', # Control-I (9) (Identical to '\t') + '\x0a': 'ctrl_j', # Control-J (10) (Identical to '\n') + '\x0b': 'ctrl_k', # Control-K (delete until end of line; vertical tab) + '\x0c': 'ctrl_l', # Control-L (clear; form feed) + '\x0d': 'ctrl_m', # Control-M (13) (Identical to '\r') + '\x0e': 'ctrl_n', # Control-N (14) (history forward) + '\x0f': 'ctrl_o', # Control-O (15) + '\x10': 'ctrl_p', # Control-P (16) (history back) + '\x11': 'ctrl_q', # Control-Q + '\x12': 'ctrl_r', # Control-R (18) (reverse search) + '\x13': 'ctrl_s', # Control-S (19) (forward search) + '\x14': 'ctrl_t', # Control-T + '\x15': 'ctrl_u', # Control-U + '\x16': 'ctrl_v', # Control-V + '\x17': 'ctrl_w', # Control-W + '\x18': 'ctrl_x', # Control-X + '\x19': 'ctrl_y', # Control-Y (25) + '\x1a': 'ctrl_z', # Control-Z + '\x1c': 'ctrl_backslash', # Both Control-\ and Ctrl-| + '\x1d': 'ctrl_square_close', # Control-] + '\x1e': 'ctrl_circumflex', # Control-^ + '\x1f': 'ctrl_underscore', # Control-underscore (Also for Ctrl-hypen.) + '\x7f': 'backspace', # (127) Backspace + ### '\x1b': 'escape', + '\x1b[A': 'arrow_up', + '\x1b[B': 'arrow_down', + '\x1b[C': 'arrow_right', + '\x1b[D': 'arrow_left', + '\x1b[H': 'home', + '\x1b[F': 'end', + '\x1b[3~': 'delete', + '\x1b[1~': 'home', # tmux + '\x1b[4~': 'end', # tmux + '\x1b[5~': 'page_up', + '\x1b[6~': 'page_down', + '\x1b[7~': 'home', # xrvt + '\x1b[8~': 'end', # xrvt + '\x1b[Z': 'backtab', # shift + tab + + '\x1bOP': 'F1', + '\x1bOQ': 'F2', + '\x1bOR': 'F3', + '\x1bOS': 'F4', + '\x1b[15~': 'F5', + '\x1b[17~': 'F6', + '\x1b[18~': 'F7', + '\x1b[19~': 'F8', + '\x1b[20~': 'F9', + '\x1b[21~': 'F10', + '\x1b[23~': 'F11', + '\x1b[24~': 'F12', + '\x1b[25~': 'F13', + '\x1b[26~': 'F14', + '\x1b[28~': 'F15', + '\x1b[29~': 'F16', + '\x1b[31~': 'F17', + '\x1b[32~': 'F18', + '\x1b[33~': 'F19', + '\x1b[34~': 'F20', + } + + def __init__(self, handler, stdout=None): + self._start_parser() + self._handler = handler + + # Put the terminal in cursor mode. (Instead of application mode.) + if stdout: + stdout.write('\x1b[?1l') + stdout.flush() + + # Put the terminal in application mode. + #print('\x1b[?1h') + + def _start_parser(self): + """ + Start the parser coroutine. + """ + self._input_parser = self._input_parser_generator() + self._input_parser.send(None) + + def _input_parser_generator(self): + """ + Coroutine (state machine) for the input parser. + """ + buffer = '' + + while True: + options = self.CALLBACKS + prefix = '' + + while True: + if buffer: + c, buffer = buffer[0], buffer[1:] + else: + c = yield + + # When we have a match -> call handler + if c in options: + self._call_handler(options[c]) + break # Reset. Go back to outer loop + + # When the first character matches -> pop first letters in options dict + elif c in [ k[0] for k in options.keys() ]: + options = { k[1:]: v for k, v in options.items() if k[0] == c } + prefix += c + + # An 'invalid' sequence, take the first char as literal, and + # start processing the rest again by shifting it in a temp + # variable. + elif prefix: + if prefix[0] == '\x1b': + self._call_handler('escape') + else: + self._call_handler('insert_char', prefix[0]) + + buffer = prefix[1:] + c + break # Reset. Go back to outer loop + + # Handle letter (no match was found.) + else: + self._call_handler('insert_char', c) + break # Reset. Go back to outer loop + + def _call_handler(self, name, *a): + """ + Callback to handler. + """ + self._handler(name, *a) + + def feed(self, data): + """ + Feed the input stream. + """ + assert isinstance(data, six.text_type) + + #print(repr(data)) + + try: + for c in data: + self._input_parser.send(c) + except Exception as e: + # Restart the parser in case of an exception. + # (The parse coroutine will go into `StopIteration` otherwise.) + self._start_parser() + raise diff --git a/prompt_toolkit/inputstream_handler.py b/prompt_toolkit/inputstream_handler.py new file mode 100644 index 000000000..e52b6e5eb --- /dev/null +++ b/prompt_toolkit/inputstream_handler.py @@ -0,0 +1,1085 @@ +""" +An :class:`~.InputStreamHandler` receives callbacks for the keystrokes parsed +from the input in the :class:`~prompt_toolkit.inputstream.InputStream` +instance. + +The `InputStreamHandler` will according to the implemented keybindings apply +the correct manipulations on the :class:`~prompt_toolkit.line.Line` object. + +This module implements Vi and Emacs keybindings. +""" +from __future__ import unicode_literals +from .line import ReturnInput, Abort, ClipboardData, ClipboardDataType +from .enums import IncrementalSearchDirection, LineMode + +__all__ = ( + 'InputStreamHandler', + 'EmacsInputStreamHandler', + 'ViInputStreamHandler' +) + + +class InputStreamHandler(object): + """ + This is the base class for :class:`~.EmacsInputStreamHandler` and + :class:`~.ViInputStreamHandler`. It implements the common keybindings. + + :attr line: :class:`~prompt_toolkit.line.Line` class. + """ + def __init__(self, line): + self._line = line + self._reset() + + def _reset(self): + #: True when the user pressed on the 'tab' key. + self._second_tab = False + + #: The name of the last previous public function call. + self._last_call = None + + self.__arg_count = None + + @property + def _arg_count(self): + """ 'arg' count. For command repeats. """ + return self.__arg_count + + @_arg_count.setter + def _arg_count(self, value): + self.__arg_count = value + + # Set argument prompt + if value: + self._line.set_arg_prompt(value) + else: + self._line.set_arg_prompt('') + + def __call__(self, name, *a): + if name != 'ctrl_i': + self._second_tab = False + + # Call actual handler + method = getattr(self, name, None) + if method: + # First, safe current state to undo stack + if self._needs_to_save(name): + self._line.save_to_undo_stack() + + try: + method(*a) + except (Abort, ReturnInput): + # Reset state when the input has been accepted/aborted. + self._reset() + raise + + # Keep track of what the last called method was. + if not name.startswith('_'): + self._last_call = name + + def _needs_to_save(self, current_method): + """ + `True` when we need to save the line of the line before calling this method. + """ + # But don't create an entry in the history buffer for every single typed + # character. (Undo should undo multiple typed characters at once.) + return not (current_method == 'insert_char' and self._last_call == 'insert_char') + + def home(self): + self._line.home() + + def end(self): + self._line.end() + + # CTRL keys. + + def ctrl_a(self): + self._line.cursor_to_start_of_line() + + def ctrl_b(self): + self._line.cursor_left() + + def ctrl_c(self): + self._line.abort() + + def ctrl_d(self): + # When there is text, act as delete, otherwise call exit. + if self._line.text: + self._line.delete() + else: + self._line.exit() + + def ctrl_e(self): + self._line.cursor_to_end_of_line() + + def ctrl_f(self): + self._line.cursor_right() + + def ctrl_g(self): + """ Abort an incremental search and restore the original line """ + self._line.exit_isearch(restore_original_line=True) + + def ctrl_h(self): + self._line.delete_character_before_cursor() + + def ctrl_i(self): + r""" Ctrl-I is identical to "\t" """ + self.tab() + + def ctrl_j(self): + """ Newline.""" + self.enter() + + def ctrl_k(self): + data = ClipboardData(self._line.delete_until_end_of_line()) + self._line.set_clipboard(data) + + def ctrl_l(self): + self._line.clear() + + def ctrl_m(self): + """ Carriage return """ + # Alias for newline. + self.ctrl_j() + + def ctrl_n(self): + self._line.history_forward() + + def ctrl_o(self): + pass + + def ctrl_p(self): + self._line.history_backward() + + def ctrl_q(self): + pass + + def ctrl_r(self): + self._line.reverse_search() + + def ctrl_s(self): + self._line.forward_search() + + def ctrl_t(self): + self._line.swap_characters_before_cursor() + + def ctrl_u(self): + """ + Clears the line before the cursor position. If you are at the end of + the line, clears the entire line. + """ + data = self._line.delete_from_start_of_line() + self._line.set_clipboard(ClipboardData(data)) + + def ctrl_v(self): + pass + + def ctrl_w(self): + """ + Delete the word before the cursor. + """ + data = ClipboardData(''.join( + self._line.delete_word_before_cursor() for i in range(self._arg_count or 1))) + self._line.set_clipboard(data) + + def ctrl_x(self): + pass + + def ctrl_y(self): + # Pastes the clipboard content. + self._line.paste_from_clipboard() + + def ctrl_z(self): + pass + + def page_up(self): + if self._line.mode == LineMode.COMPLETE: + self._line.complete_previous(5) + else: + self._line.history_backward() + + def page_down(self): + if self._line.mode == LineMode.COMPLETE: + self._line.complete_next(5) + else: + self._line.history_forward() + + def arrow_left(self): + self._line.cursor_left() + + def arrow_right(self): + self._line.cursor_right() + + def arrow_up(self): + self._line.auto_up() + + def arrow_down(self): + self._line.auto_down() + + def backspace(self): + self._line.delete_character_before_cursor() + + def delete(self): + self._line.delete() + + def tab(self): + """ + Autocomplete. + """ + if self._second_tab: + self._line.list_completions() + self._second_tab = False + else: + self._second_tab = not self._line.complete() + + def insert_char(self, data): + """ Insert data at cursor position. """ + assert len(data) == 1 + self._line.insert_text(data) + + def enter(self): + if self._line.mode == LineMode.INCREMENTAL_SEARCH: + # When enter pressed in isearch, quit isearch mode. (Multiline + # isearch would be too complicated.) + self._line.exit_isearch() + + elif self._line.is_multiline: + self._line.newline() + else: + self._line.return_input() + + +class EmacsInputStreamHandler(InputStreamHandler): + """ + Some e-macs extensions. + """ + # Overview of Readline emacs commands: + # http://www.catonmat.net/download/readline-emacs-editing-mode-cheat-sheet.pdf + + def _reset(self): + super(EmacsInputStreamHandler, self)._reset() + self._escape_pressed = False + self._ctrl_x_pressed = False + + def escape(self): + # Escape is the same as the 'meta-' prefix. + self._escape_pressed = True + + def ctrl_n(self): + self._line.auto_down() + + def ctrl_o(self): + """ Insert newline, but don't move the cursor. """ + self._line.insert_text('\n', move_cursor=False) + + def ctrl_p(self): + self._line.auto_up() + + def ctrl_w(self): + # TODO: cut current region. + pass + + def ctrl_x(self): + self._ctrl_x_pressed = True + + def ctrl_y(self): + """ Paste before cursor. """ + self._line.paste_from_clipboard(before=True) + + def __call__(self, name, *a): + reset_arg_count_after_call = True + + if name == 'ctrl_x': + reset_arg_count_after_call = False + + # TODO: implement these states (meta-prefix and ctrl_x) + # in separate InputStreamHandler classes.If a method, like (ctl_x) + # is called and returns an object. That should become the + # new handler. + + # When escape was pressed, call the `meta_`-function instead. + # (This is emacs-mode behaviour. The meta-prefix is equal to the escape + # key, and in VI mode, that's used to go from insert to navigation mode.) + if self._escape_pressed: + if name == 'insert_char': + # Handle Alt + digit in the `meta_digit` method. + if a[0] in '0123456789' or (a[0] == '-' and self._arg_count == None): + name = 'meta_digit' + reset_arg_count_after_call = False + + # Handle Alt + char in their respective `meta_X` method. + else: + name = 'meta_' + a[0] + a = [] + else: + name = 'meta_' + name + self._escape_pressed = False + + # If Ctrl-x was pressed. Prepend ctrl_x prefix to hander name. + if self._ctrl_x_pressed: + name = 'ctrl_x_%s' % name + + super(EmacsInputStreamHandler, self).__call__(name, *a) + + # Reset arg count. + if name == 'escape': + reset_arg_count_after_call = False + + if reset_arg_count_after_call: + self._arg_count = None + + # Reset ctrl_x state. + if name != 'ctrl_x': + self._ctrl_x_pressed = False + + def _needs_to_save(self, current_method): + # Don't save the current state at the undo-stack for following methods. + if current_method in ('ctrl_x', 'ctrl_x_ctrl_u', 'ctrl_underscore'): + return False + + return super(EmacsInputStreamHandler, self)._needs_to_save(current_method) + + def insert_char(self, data): + for i in range(self._arg_count or 1): + super(EmacsInputStreamHandler, self).insert_char(data) + + def meta_ctrl_j(self): + """ ALT + Newline """ + # Alias for meta_enter + self.meta_enter() + + def meta_ctrl_m(self): + """ ALT + Carriage return """ + # Alias for meta_enter + self.meta_enter() + + def meta_digit(self, digit): + """ ALT + digit or '-' pressed. """ + self._arg_count = _arg_count_append(self._arg_count, digit) + + def meta_enter(self): + """ Alt + Enter. Should always accept input. """ + self._line.return_input() + + def meta_backspace(self): + """ Delete word backwards. """ + self._line.delete_word_before_cursor() + + def meta_a(self): + """ + Previous sentence. + """ + # TODO: + pass + + def meta_c(self): + """ + Capitalize the current (or following) word. + """ + for i in range(self._arg_count or 1): + pos = self._line.document.find_next_word_ending() + words = self._line.document.text_after_cursor[:pos] + self._line.insert_text(words.title(), overwrite=True) + + def meta_e(self): + """ Move to end of sentence. """ + # TODO: + pass + + def meta_f(self): + """ + Cursor to end of next word. + """ + pos = self._line.document.find_next_word_ending() + if pos: + self._line.cursor_position += pos + + def meta_b(self): + """ Cursor to start of previous word. """ + self._line.cursor_word_back() + + def meta_d(self): + """ + Delete the Word after the cursor. (Delete until end of word.) + """ + pos = self._line.document.find_next_word_ending() + data = ClipboardData(self._line.delete(pos)) + self._line.set_clipboard(data) + + def meta_l(self): + """ + Lowercase the current (or following) word. + """ + for i in range(self._arg_count or 1): # XXX: not DRY: see meta_c and meta_u!! + pos = self._line.document.find_next_word_ending() + words = self._line.document.text_after_cursor[:pos] + self._line.insert_text(words.lower(), overwrite=True) + + def meta_t(self): + """ + Swap the last two words before the cursor. + """ + # TODO + + def meta_u(self): + """ + Uppercase the current (or following) word. + """ + for i in range(self._arg_count or 1): + pos = self._line.document.find_next_word_ending() + words = self._line.document.text_after_cursor[:pos] + self._line.insert_text(words.upper(), overwrite=True) + + def meta_w(self): + """ + Copy current region. + """ + # TODO + + def ctrl_space(self): + """ + Select region. + """ + # TODO + pass + + def ctrl_underscore(self): + """ + Undo. + """ + self._line.undo() + + def meta_backslash(self): + """ + Delete all spaces and tabs around point. + (delete-horizontal-space) + """ + + def meta_star(self): + """ + `meta-*`: Insert all possible completions of the preceding text. + """ + + def ctrl_x_ctrl_e(self): + """ + Open editor. + """ + self._line.open_in_editor() + + def ctrl_x_ctrl_u(self): + self._line.undo() + + def ctrl_x_ctrl_x(self): + """ + Move cursor back and forth between the start and end of the current + line. + """ + if self._line.document.current_char == '\n': + self._line.cursor_to_start_of_line(after_whitespace=False) + else: + self._line.cursor_to_end_of_line() + + +class ViMode(object): + NAVIGATION = 'navigation' + INSERT = 'insert' + REPLACE = 'replace' + + # TODO: Not supported. But maybe for some day... + VISUAL = 'visual' + VISUAL_LINE = 'visual-line' + VISUAL_BLOCK = 'visual-block' + + +class ViInputStreamHandler(InputStreamHandler): + """ + Vi extensions. + + # Overview of Readline Vi commands: + # http://www.catonmat.net/download/bash-vi-editing-mode-cheat-sheet.pdf + """ + def _reset(self): + super(ViInputStreamHandler, self)._reset() + self._vi_mode = ViMode.INSERT + self._all_navigation_handles = self._get_navigation_mode_handles() + + # Hook for several actions in navigation mode which require an + # additional key to be typed before they execute. + self._one_character_callback = None + + # Remember the last 'F' or 'f' command. + self._last_character_find = None # (char, backwards) tuple + + # Macros. + self._macro_recording_register = None + self._macro_recording_calls = [] # List of currently recording commands. + self._macros = {} # Maps macro char to commands. + self._playing_macro = False + + @property + def is_recording_macro(self): + """ True when we are currently recording a macro. """ + return bool(self._macro_recording_register) + + def __call__(self, name, *a): + # Save in macro, if we are recording. + if self._macro_recording_register: + self._macro_recording_calls.append( (name,) + a) + + super(ViInputStreamHandler, self).__call__(name, *a) + + # After every command, make sure that if we are in navigation mode, we + # never put the cursor after the last character of a line. (Unless it's + # an empty line.) + if ( + self._vi_mode == ViMode.NAVIGATION and + self._line.document.cursor_at_the_end_of_line and + len(self._line.document.current_line) > 0): + self._line.cursor_position -= 1 + + def _needs_to_save(self, current_method): + # Don't create undo entries in the middle of executing a macro. + # (We want to be able to undo the macro in its whole.) + if self._playing_macro: + return False + + return super(ViInputStreamHandler, self)._needs_to_save(current_method) + + def escape(self): + """ Escape goes to vi navigation mode. """ + self._vi_mode = ViMode.NAVIGATION + self._current_handles = self._all_navigation_handles + + # Reset arg count. + self._arg_count = None + + # Quit incremental search (if enabled.) + if self._line.mode == LineMode.INCREMENTAL_SEARCH: + self._line.exit_isearch() + + def enter(self): + if self._line.mode == LineMode.INCREMENTAL_SEARCH: + self._line.exit_isearch(restore_original_line=False) + + elif self._vi_mode == ViMode.NAVIGATION: + self._vi_mode = ViMode.INSERT + self._line.return_input() + + else: + super(ViInputStreamHandler, self).enter() + + def backspace(self): + # In Vi-mode, either move cursor or delete character. + if self._vi_mode == ViMode.INSERT: + self._line.delete_character_before_cursor() + else: + self._line.cursor_left() + + def ctrl_v(self): + # TODO: Insert a character literally (quoted insert). + pass + + def ctrl_n(self): + self._line.complete_next() + + def ctrl_p(self): + self._line.complete_previous() + + def _get_navigation_mode_handles(self): + """ + Create a dictionary that maps the vi key binding to their handlers. + """ + handles = {} + line = self._line + + def handle(key): + """ Decorator that registeres the handler function in the handles dict. """ + def wrapper(func): + handles[key] = func + return func + return wrapper + + # List of navigation commands: http://hea-www.harvard.edu/~fine/Tech/vi.html + + @handle('a') + def _(arg): + self._vi_mode = ViMode.INSERT + line.cursor_right() + + @handle('A') + def _(arg): + self._vi_mode = ViMode.INSERT + line.cursor_to_end_of_line() + + @handle('b') # Move one word or token left. + @handle('B') # Move one non-blank word left ((# TODO: difference between 'b' and 'B') + def _(arg): + for i in range(arg): + line.cursor_word_back() + + @handle('C') + @handle('c$') + def _(arg): + # Change to end of line. + data = ClipboardData(line.delete_until_end_of_line()) + line.set_clipboard(data) + self._vi_mode = ViMode.INSERT + + @handle('cc') + @handle('S') + def _(arg): # TODO: implement 'arg' + """ Change current line """ + # We copy the whole line. + data = ClipboardData(line.document.current_line, ClipboardDataType.LINES) + line.set_clipboard(data) + + # But we delete after the whitespace + line.cursor_to_start_of_line(after_whitespace=True) + line.delete_until_end_of_line() + self._vi_mode = ViMode.INSERT + + @handle('cw') + @handle('ce') + def _(arg): + data = ClipboardData(''.join(line.delete_word() for i in range(arg))) + line.set_clipboard(data) + self._vi_mode = ViMode.INSERT + + @handle('D') + def _(arg): + data = ClipboardData(line.delete_until_end_of_line()) + line.set_clipboard(data) + + @handle('dd') + def _(arg): + text = '\n'.join(line.delete_current_line() for i in range(arg)) + data = ClipboardData(text, ClipboardDataType.LINES) + line.set_clipboard(data) + + @handle('d$') + def _(arg): + # Delete until end of line. + data = ClipboardData(line.delete_until_end_of_line()) + line.set_clipboard(data) + + @handle('dw') + def _(arg): + data = ClipboardData(''.join(line.delete_word() for i in range(arg))) + line.set_clipboard(data) + + @handle('e') # Move to the end of the current word + @handle('E') # Move to the end of the current non-blank word. (# TODO: diff between 'e' and 'E') + def _(arg): + # End of word + line.cursor_to_end_of_word() + + @handle('f') + def _(arg): + # Go to next occurance of character. Typing 'fx' will move the + # cursor to the next occurance of character. 'x'. + def cb(char): + self._last_character_find = (char, False) + + for i in range(arg): + line.go_to_substring(char, in_current_line=True) + self._one_character_callback = cb + + @handle('F') + def _(arg): + # Go to previous occurance of character. Typing 'Fx' will move the + # cursor to the previous occurance of character. 'x'. + def cb(char): + self._last_character_find = (char, True) + + for i in range(arg): + line.go_to_substring(char, in_current_line=True, backwards=True) + self._one_character_callback = cb + + @handle('G') + def _(arg): + # Move to the history line n (you may specify the argument n by + # typing it on number keys, for example, 15G) + if arg < len(line._working_lines) + 1: + line._working_index = arg - 1 + + @handle('h') + def _(arg): + for i in range(arg): + line.cursor_left() + + @handle('H') + def _(arg): + # Vi moves to the start of the visible region. + # cursor position 0 is okay for us. + line.cursor_position = 0 + + @handle('i') + def _(arg): + self._vi_mode = ViMode.INSERT + + @handle('I') + def _(arg): + self._vi_mode = ViMode.INSERT + line.cursor_to_start_of_line(after_whitespace=True) + + @handle('j') + def _(arg): + for i in range(arg): + line.auto_down() + + @handle('J') + def _(arg): + line.join_next_line() + + @handle('k') + def _(arg): + for i in range(arg): + line.auto_up() + + @handle('l') + @handle(' ') + def _(arg): + for i in range(arg): + line.cursor_right() + + @handle('L') + def _(arg): + # Vi moves to the start of the visible region. + # cursor position 0 is okay for us. + line.cursor_position = len(line.text) + + @handle('n') + def _(arg): + # TODO: + pass + + # if line.isearch_state: + # # Repeat search in the same direction as previous. + # line.search_next(line.isearch_state.isearch_direction) + + @handle('N') + def _(arg): + # TODO: + pass + + #if line.isearch_state: + # # Repeat search in the opposite direction as previous. + # if line.isearch_state.isearch_direction == IncrementalSearchDirection.FORWARD: + # line.search_next(IncrementalSearchDirection.BACKWARD) + # else: + # line.search_next(IncrementalSearchDirection.FORWARD) + + @handle('p') + def _(arg): + # Paste after + for i in range(arg): + line.paste_from_clipboard() + + @handle('P') + def _(arg): + # Paste before + for i in range(arg): + line.paste_from_clipboard(before=True) + + @handle('r') + def _(arg): + # Replace single character under cursor + def cb(char): + line.insert_text(char * arg, overwrite=True) + self._one_character_callback = cb + + @handle('R') + def _(arg): + # Go to 'replace'-mode. + self._vi_mode = ViMode.REPLACE + + @handle('s') + def _(arg): + # Substitute with new text + # (Delete character(s) and go to insert mode.) + data = ClipboardData(''.join(line.delete() for i in range(arg))) + line.set_clipboard(data) + self._vi_mode = ViMode.INSERT + + @handle('t') + def _(arg): + # Move right to the next occurance of c, then one char backward. + def cb(char): + for i in range(arg): + line.go_to_substring(char, in_current_line=True) + line.cursor_left() + self._one_character_callback = cb + + @handle('T') + def _(arg): + # Move left to the previous occurance of c, then one char forward. + def cb(char): + for i in range(arg): + line.go_to_substring(char, in_current_line=True, backwards=True) + line.cursor_right() + self._one_character_callback = cb + + @handle('u') + def _(arg): + for i in range(arg): + line.undo() + + @handle('v') + def _(arg): + line.open_in_editor() + + @handle('w') # Move one word or token right. + @handle('W') # Move one non-blank word right. (# TODO: difference between 'w' and 'W') + def _(arg): + for i in range(arg): + line.cursor_word_forward() + + @handle('x') + def _(arg): + # Delete character. + data = ClipboardData(''.join(line.delete() for i in range(arg))) + line.set_clipboard(data) + + @handle('X') + def _(arg): + line.delete_character_before_cursor() + + @handle('yy') + def _(arg): + # Yank the whole line. + text = '\n'.join(line.document.lines_from_current[:arg]) + + data = ClipboardData(text, ClipboardDataType.LINES) + line.set_clipboard(data) + + @handle('yw') + def _(arg): + # Yank word. + pass + + @handle('^') + def _(arg): + line.cursor_to_start_of_line(after_whitespace=True) + + @handle('0') + def _(arg): + # Move to the beginning of line. + line.cursor_to_start_of_line(after_whitespace=False) + + @handle('$') + def _(arg): + line.cursor_to_end_of_line() + + @handle('%') + def _(arg): + # Move to the corresponding opening/closing bracket (()'s, []'s and {}'s). + line.go_to_matching_bracket() + + @handle('+') + def _(arg): + # Move to first non whitespace of next line + for i in range(arg): + line.cursor_down() + line.cursor_to_start_of_line(after_whitespace=True) + + @handle('-') + def _(arg): + # Move to first non whitespace of previous line + for i in range(arg): + line.cursor_up() + line.cursor_to_start_of_line(after_whitespace=True) + + @handle('{') + def _(arg): + # Move to previous blank-line separated section. + for i in range(arg): + index = line.document.find_previous_matching_line( + lambda text: not text or text.isspace()) + + if index is not None: + for i in range(-index): + line.cursor_up() + + @handle('}') + def _(arg): + # Move to next blank-line separated section. + for i in range(arg): + index = line.document.find_next_matching_line( + lambda text: not text or text.isspace()) + + if index is not None: + for i in range(index): + line.cursor_down() + + @handle('>>') + def _(arg): + # Indent lines. + current_line = line.document.cursor_position_row + line_range = range(current_line, current_line + arg) + line.transform_lines(line_range, lambda l: ' ' + l) + + line.cursor_to_start_of_line(after_whitespace=True) + + @handle('<<') + def _(arg): + # Unindent current line. + current_line = line.document.cursor_position_row + line_range = range(current_line, current_line + arg) + + def transform(text): + if text.startswith(' '): + return text[4:] + else: + return text.lstrip() + + line.transform_lines(line_range, transform) + line.cursor_to_start_of_line(after_whitespace=True) + + @handle('O') + def _(arg): + # Open line above and enter insertion mode + line.insert_line_above() + self._vi_mode = ViMode.INSERT + + @handle('o') + def _(arg): + # Open line below and enter insertion mode + line.insert_line_below() + self._vi_mode = ViMode.INSERT + + @handle('q') + def _(arg): + # Start/stop recording macro. + if self._macro_recording_register: + # Save macro. + self._macros[self._macro_recording_register] = self._macro_recording_calls + self._macro_recording_register = None + else: + # Start new macro. + def cb(char): + self._macro_recording_register = char + self._macro_recording_calls = [] + + self._one_character_callback = cb + + @handle('@') + def _(arg): + # Execute macro. + def cb(char): + if char in self._macros: + self._playing_macro = True + + for command in self._macros[char]: + self(*command) + + self._playing_macro = False + + self._one_character_callback = cb + + @handle('~') + def _(arg): + """ Reverse case of current character and move cursor forward. """ + c = line.document.current_char + if c is not None and c != '\n': + c = (c.upper() if c.islower() else c.lower()) + line.insert_text(c, overwrite=True) + + @handle('|') + def _(arg): + # Move to the n-th column (you may specify the argument n by typing + # it on number keys, for example, 20|). + line.go_to_column(arg) + + @handle('/') + def _(arg): + # Search history backward for a command matching string. + self._line.reverse_search() + self._vi_mode = ViMode.INSERT # We have to be able to insert the search string. + + @handle('?') + def _(arg): + # Search history forward for a command matching string. + self._line.forward_search() + self._vi_mode = ViMode.INSERT # We have to be able to insert the search string. + + @handle(';') + def _(arg): + # Repeat the last 'f' or 'F' command. + if self._last_character_find: + char, backwards = self._last_character_find + + for i in range(arg): + line.go_to_substring(char, in_current_line=True, backwards=backwards) + + return handles + + def insert_char(self, data): + """ Insert data at cursor position. """ + assert len(data) == 1 + + if self._one_character_callback: + self._one_character_callback(data) + self._one_character_callback = False + + elif self._line.mode == LineMode.INCREMENTAL_SEARCH: + self._line.insert_text(data) + + elif self._vi_mode == ViMode.NAVIGATION: + # Always handle numberics to build the arg + if data in '123456789' or (self._arg_count and data == '0'): + self._arg_count = _arg_count_append(self._arg_count, data) + + # If we have a handle for the current keypress. Call it. + elif data in self._current_handles: + # Pass argument to handle. + arg_count = self._arg_count + self._arg_count = None + + # Safe state (except if we called the 'undo' action.) + if data != 'u': + self._line.save_to_undo_stack() + + # Call handler + self._current_handles[data](arg_count or 1) + self._current_handles = self._all_navigation_handles + + # If there are several combitations of handles, starting with the + # keys that were already pressed. Reduce to this subset of + # handlers. + elif data in [ k[0] for k in self._current_handles.keys() ]: + self._current_handles = { k[1:]:h for k, h in self._current_handles.items() if k[0] == data } + + # No match. Reset. + else: + self._current_handles = self._all_navigation_handles + + # In replace/text mode. + elif self._vi_mode == ViMode.REPLACE: + self._line.insert_text(data, overwrite=True) + + # In insert/text mode. + elif self._vi_mode == ViMode.INSERT: + super(ViInputStreamHandler, self).insert_char(data) + + +def _arg_count_append(current, data): + """ + Utility for manupulating the arg-count string. + + :param current: int or None + :param data: the typed digit as string + :returns: int or None + """ + assert data in '-0123456789' + + if current is None: + if data == '-': + data = '-1' + result = int(data) + else: + result = int("%s%s" % (current, data)) + + # Don't exceed a million. + if int(result) >= 1000000: + result = None + + return result diff --git a/prompt_toolkit/libs/__init__.py b/prompt_toolkit/libs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/prompt_toolkit/libs/wcwidth.py b/prompt_toolkit/libs/wcwidth.py new file mode 100644 index 000000000..d22709f46 --- /dev/null +++ b/prompt_toolkit/libs/wcwidth.py @@ -0,0 +1,349 @@ +""" +wcwidth.py: + +source: https://github.com/jquast/x84/blob/master/x84/bbs/wcwidth.py#L197 +author: Jeffrey Quast +""" + + +""" +Translated from Markus Kuhn's C code at: + + http://www.cl.cam.ac.uk/~mgk25/ucs/wcwidth.c + + +""" +# This is an implementation of wcwidth() and wcswidth() (defined in +# IEEE Std 1002.1-2001) for Unicode. +# +# http://www.opengroup.org/onlinepubs/007904975/functions/wcwidth.html +# http://www.opengroup.org/onlinepubs/007904975/functions/wcswidth.html +# +# In fixed-width output devices, Latin characters all occupy a single +# "cell" position of equal width, whereas ideographic CJK characters +# occupy two such cells. Interoperability between terminal-line +# applications and (teletype-style) character terminals using the +# UTF-8 encoding requires agreement on which character should advance +# the cursor by how many cell positions. No established formal +# standards exist at present on which Unicode character shall occupy +# how many cell positions on character terminals. These routines are +# a first attempt of defining such behavior based on simple rules +# applied to data provided by the Unicode Consortium. +# +# For some graphical characters, the Unicode standard explicitly +# defines a character-cell width via the definition of the East Asian +# FullWidth (F), Wide (W), Half-width (H), and Narrow (Na) classes. +# In all these cases, there is no ambiguity about which width a +# terminal shall use. For characters in the East Asian Ambiguous (A) +# class, the width choice depends purely on a preference of backward +# compatibility with either historic CJK or Western practice. +# Choosing single-width for these characters is easy to justify as +# the appropriate long-term solution, as the CJK practice of +# displaying these characters as double-width comes from historic +# implementation simplicity (8-bit encoded characters were displayed +# single-width and 16-bit ones double-width, even for Greek, +# Cyrillic, etc.) and not any typographic considerations. +# +# Much less clear is the choice of width for the Not East Asian +# (Neutral) class. Existing practice does not dictate a width for any +# of these characters. It would nevertheless make sense +# typographically to allocate two character cells to characters such +# as for instance EM SPACE or VOLUME INTEGRAL, which cannot be +# represented adequately with a single-width glyph. The following +# routines at present merely assign a single-cell width to all +# neutral characters, in the interest of simplicity. This is not +# entirely satisfactory and should be reconsidered before +# establishing a formal standard in this area. At the moment, the +# decision which Not East Asian (Neutral) characters should be +# represented by double-width glyphs cannot yet be answered by +# applying a simple rule from the Unicode database content. Setting +# up a proper standard for the behavior of UTF-8 character terminals +# will require a careful analysis not only of each Unicode character, +# but also of each presentation form, something the author of these +# routines has avoided to do so far. +# +# http://www.unicode.org/unicode/reports/tr11/ +# +# Markus Kuhn -- 2003-05-20 (Unicode 4.0) +# +# Permission to use, copy, modify, and distribute this software +# for any purpose and without fee is hereby granted. The author +# disclaims all warranties with regard to this software. +# +# Latest version: http://www.cl.cam.ac.uk/~mgk25/ucs/wcwidth.c + + +def _bisearch(ucs, table): + " auxiliary function for binary search in interval table " + lbound = 0 + ubound = len(table) - 1 + + if ucs < table[0][0] or ucs > table[ubound][1]: + return 0 + while ubound >= lbound: + mid = (lbound + ubound) // 2 + if ucs > table[mid][1]: + lbound = mid + 1 + elif ucs < table[mid][0]: + ubound = mid - 1 + else: + return 1 + + return 0 + +# The following two functions define the column width of an ISO 10646 +# character as follows: +# +# - The null character (U+0000) has a column width of 0. +# +# - Other C0/C1 control characters and DEL will lead to a return +# value of -1. +# +# - Non-spacing and enclosing combining characters (general +# category code Mn or Me in the Unicode database) have a +# column width of 0. +# +# - SOFT HYPHEN (U+00AD) has a column width of 1. +# +# - Other format characters (general category code Cf in the Unicode +# database) and ZERO WIDTH SPACE (U+200B) have a column width of 0. +# +# - Hangul Jamo medial vowels and final consonants (U+1160-U+11FF) +# have a column width of 0. +# +# - Spacing characters in the East Asian Wide (W) or East Asian +# Full-width (F) category as defined in Unicode Technical +# Report #11 have a column width of 2. +# +# - All remaining characters (including all printable +# ISO 8859-1 and WGL4 characters, Unicode control characters, +# etc.) have a column width of 1. +# +# This implementation assumes that wchar_t characters are encoded +# in ISO 10646. + +# sorted list of non-overlapping intervals of non-spacing characters +# generated by "uniset +cat=Me +cat=Mn +cat=Cf -00AD +1160-11FF +200B c" + +_COMBINING = [ + (0x0300, 0x0357), (0x035D, 0x036F), (0x0483, 0x0486), + (0x0488, 0x0489), (0x0591, 0x05A1), (0x05A3, 0x05B9), + (0x05BB, 0x05BD), (0x05BF, 0x05BF), (0x05C1, 0x05C2), + (0x05C4, 0x05C4), (0x0600, 0x0603), (0x0610, 0x0615), + (0x064B, 0x0658), (0x0670, 0x0670), (0x06D6, 0x06E4), + (0x06E7, 0x06E8), (0x06EA, 0x06ED), (0x070F, 0x070F), + (0x0711, 0x0711), (0x0730, 0x074A), (0x07A6, 0x07B0), + (0x0901, 0x0902), (0x093C, 0x093C), (0x0941, 0x0948), + (0x094D, 0x094D), (0x0951, 0x0954), (0x0962, 0x0963), + (0x0981, 0x0981), (0x09BC, 0x09BC), (0x09C1, 0x09C4), + (0x09CD, 0x09CD), (0x09E2, 0x09E3), (0x0A01, 0x0A02), + (0x0A3C, 0x0A3C), (0x0A41, 0x0A42), (0x0A47, 0x0A48), + (0x0A4B, 0x0A4D), (0x0A70, 0x0A71), (0x0A81, 0x0A82), + (0x0ABC, 0x0ABC), (0x0AC1, 0x0AC5), (0x0AC7, 0x0AC8), + (0x0ACD, 0x0ACD), (0x0AE2, 0x0AE3), (0x0B01, 0x0B01), + (0x0B3C, 0x0B3C), (0x0B3F, 0x0B3F), (0x0B41, 0x0B43), + (0x0B4D, 0x0B4D), (0x0B56, 0x0B56), (0x0B82, 0x0B82), + (0x0BC0, 0x0BC0), (0x0BCD, 0x0BCD), (0x0C3E, 0x0C40), + (0x0C46, 0x0C48), (0x0C4A, 0x0C4D), (0x0C55, 0x0C56), + (0x0CBC, 0x0CBC), (0x0CBF, 0x0CBF), (0x0CC6, 0x0CC6), + (0x0CCC, 0x0CCD), (0x0D41, 0x0D43), (0x0D4D, 0x0D4D), + (0x0DCA, 0x0DCA), (0x0DD2, 0x0DD4), (0x0DD6, 0x0DD6), + (0x0E31, 0x0E31), (0x0E34, 0x0E3A), (0x0E47, 0x0E4E), + (0x0EB1, 0x0EB1), (0x0EB4, 0x0EB9), (0x0EBB, 0x0EBC), + (0x0EC8, 0x0ECD), (0x0F18, 0x0F19), (0x0F35, 0x0F35), + (0x0F37, 0x0F37), (0x0F39, 0x0F39), (0x0F71, 0x0F7E), + (0x0F80, 0x0F84), (0x0F86, 0x0F87), (0x0F90, 0x0F97), + (0x0F99, 0x0FBC), (0x0FC6, 0x0FC6), (0x102D, 0x1030), + (0x1032, 0x1032), (0x1036, 0x1037), (0x1039, 0x1039), + (0x1058, 0x1059), (0x1160, 0x11FF), (0x1712, 0x1714), + (0x1732, 0x1734), (0x1752, 0x1753), (0x1772, 0x1773), + (0x17B4, 0x17B5), (0x17B7, 0x17BD), (0x17C6, 0x17C6), + (0x17C9, 0x17D3), (0x17DD, 0x17DD), (0x180B, 0x180D), + (0x18A9, 0x18A9), (0x1920, 0x1922), (0x1927, 0x1928), + (0x1932, 0x1932), (0x1939, 0x193B), (0x200B, 0x200F), + (0x202A, 0x202E), (0x2060, 0x2063), (0x206A, 0x206F), + (0x20D0, 0x20EA), (0x302A, 0x302F), (0x3099, 0x309A), + (0xFB1E, 0xFB1E), (0xFE00, 0xFE0F), (0xFE20, 0xFE23), + (0xFEFF, 0xFEFF), (0xFFF9, 0xFFFB), (0x1D167, 0x1D169), + (0x1D173, 0x1D182), (0x1D185, 0x1D18B), (0x1D1AA, 0x1D1AD), + (0xE0001, 0xE0001), (0xE0020, 0xE007F), (0xE0100, 0xE01EF) +] + + +def wcwidth(char): + """ + Return the width in character cells of the Unicode character + whose code is c + """ + + ucs = ord(char) + # test for 8-bit control characters + if ucs == 0: + return 0 + if ucs < 32 or (ucs >= 0x7f and ucs < 0xa0): + return -1 + + # binary search in table of non-spacing characters + if _bisearch(ucs, _COMBINING): + return 0 + + # if we arrive here, ucs is not a combining or C0/C1 control character + + return 1 + \ + (ucs >= 0x1100 and + (ucs <= 0x115f or # Hangul Jamo init. consonants + ucs == 0x2329 or ucs == 0x232a or + (ucs >= 0x2e80 and ucs <= 0xa4cf and + ucs != 0x303f) or # CJK ... Yi + (ucs >= 0xac00 and ucs <= 0xd7a3) or # Hangul Syllables + (ucs >= 0xf900 and ucs <= 0xfaff) or # CJK Compatibility Ideographs * + (ucs >= 0xfe30 and ucs <= 0xfe6f) or # CJK Compatibility Forms + (ucs >= 0xff00 and ucs <= 0xff60) or # Fullwidth Forms + (ucs >= 0xffe0 and ucs <= 0xffe6) or + (ucs >= 0x20000 and ucs <= 0x2fffd) or + (ucs >= 0x30000 and ucs <= 0x3fffd))) + + +def wcswidth(pwcs): + + """ + Return the width in character cells of the unicode string pwcs, + or -1 if the string contains non-printable characters. + """ + + width = 0 + for char in pwcs: + wcw = wcwidth(char) + if wcw < 0: + return -1 + else: + width += wcw + return width + + +# sorted list of non-overlapping intervals of East Asian Ambiguous +# characters, generated by "uniset +WIDTH-A -cat=Me -cat=Mn -cat=Cf c" +_AMBIGUOUS = [ + (0x00A1, 0x00A1), (0x00A4, 0x00A4), (0x00A7, 0x00A8), + (0x00AA, 0x00AA), (0x00AE, 0x00AE), (0x00B0, 0x00B4), + (0x00B6, 0x00BA), (0x00BC, 0x00BF), (0x00C6, 0x00C6), + (0x00D0, 0x00D0), (0x00D7, 0x00D8), (0x00DE, 0x00E1), + (0x00E6, 0x00E6), (0x00E8, 0x00EA), (0x00EC, 0x00ED), + (0x00F0, 0x00F0), (0x00F2, 0x00F3), (0x00F7, 0x00FA), + (0x00FC, 0x00FC), (0x00FE, 0x00FE), (0x0101, 0x0101), + (0x0111, 0x0111), (0x0113, 0x0113), (0x011B, 0x011B), + (0x0126, 0x0127), (0x012B, 0x012B), (0x0131, 0x0133), + (0x0138, 0x0138), (0x013F, 0x0142), (0x0144, 0x0144), + (0x0148, 0x014B), (0x014D, 0x014D), (0x0152, 0x0153), + (0x0166, 0x0167), (0x016B, 0x016B), (0x01CE, 0x01CE), + (0x01D0, 0x01D0), (0x01D2, 0x01D2), (0x01D4, 0x01D4), + (0x01D6, 0x01D6), (0x01D8, 0x01D8), (0x01DA, 0x01DA), + (0x01DC, 0x01DC), (0x0251, 0x0251), (0x0261, 0x0261), + (0x02C4, 0x02C4), (0x02C7, 0x02C7), (0x02C9, 0x02CB), + (0x02CD, 0x02CD), (0x02D0, 0x02D0), (0x02D8, 0x02DB), + (0x02DD, 0x02DD), (0x02DF, 0x02DF), (0x0391, 0x03A1), + (0x03A3, 0x03A9), (0x03B1, 0x03C1), (0x03C3, 0x03C9), + (0x0401, 0x0401), (0x0410, 0x044F), (0x0451, 0x0451), + (0x2010, 0x2010), (0x2013, 0x2016), (0x2018, 0x2019), + (0x201C, 0x201D), (0x2020, 0x2022), (0x2024, 0x2027), + (0x2030, 0x2030), (0x2032, 0x2033), (0x2035, 0x2035), + (0x203B, 0x203B), (0x203E, 0x203E), (0x2074, 0x2074), + (0x207F, 0x207F), (0x2081, 0x2084), (0x20AC, 0x20AC), + (0x2103, 0x2103), (0x2105, 0x2105), (0x2109, 0x2109), + (0x2113, 0x2113), (0x2116, 0x2116), (0x2121, 0x2122), + (0x2126, 0x2126), (0x212B, 0x212B), (0x2153, 0x2154), + (0x215B, 0x215E), (0x2160, 0x216B), (0x2170, 0x2179), + (0x2190, 0x2199), (0x21B8, 0x21B9), (0x21D2, 0x21D2), + (0x21D4, 0x21D4), (0x21E7, 0x21E7), (0x2200, 0x2200), + (0x2202, 0x2203), (0x2207, 0x2208), (0x220B, 0x220B), + (0x220F, 0x220F), (0x2211, 0x2211), (0x2215, 0x2215), + (0x221A, 0x221A), (0x221D, 0x2220), (0x2223, 0x2223), + (0x2225, 0x2225), (0x2227, 0x222C), (0x222E, 0x222E), + (0x2234, 0x2237), (0x223C, 0x223D), (0x2248, 0x2248), + (0x224C, 0x224C), (0x2252, 0x2252), (0x2260, 0x2261), + (0x2264, 0x2267), (0x226A, 0x226B), (0x226E, 0x226F), + (0x2282, 0x2283), (0x2286, 0x2287), (0x2295, 0x2295), + (0x2299, 0x2299), (0x22A5, 0x22A5), (0x22BF, 0x22BF), + (0x2312, 0x2312), (0x2460, 0x24E9), (0x24EB, 0x254B), + (0x2550, 0x2573), (0x2580, 0x258F), (0x2592, 0x2595), + (0x25A0, 0x25A1), (0x25A3, 0x25A9), (0x25B2, 0x25B3), + (0x25B6, 0x25B7), (0x25BC, 0x25BD), (0x25C0, 0x25C1), + (0x25C6, 0x25C8), (0x25CB, 0x25CB), (0x25CE, 0x25D1), + (0x25E2, 0x25E5), (0x25EF, 0x25EF), (0x2605, 0x2606), + (0x2609, 0x2609), (0x260E, 0x260F), (0x2614, 0x2615), + (0x261C, 0x261C), (0x261E, 0x261E), (0x2640, 0x2640), + (0x2642, 0x2642), (0x2660, 0x2661), (0x2663, 0x2665), + (0x2667, 0x266A), (0x266C, 0x266D), (0x266F, 0x266F), + (0x273D, 0x273D), (0x2776, 0x277F), (0xE000, 0xF8FF), + (0xFFFD, 0xFFFD), (0xF0000, 0xFFFFD), (0x100000, 0x10FFFD) +] + +# The following functions are the same as mk_wcwidth() and +# mk_wcwidth_cjk(), except that spacing characters in the East Asian +# Ambiguous (A) category as defined in Unicode Technical Report #11 +# have a column width of 2. This variant might be useful for users of +# CJK legacy encodings who want to migrate to UCS without changing +# the traditional terminal character-width behaviour. It is not +# otherwise recommended for general use. + + +def wcwidth_cjk(ucs): + """ As wcwidth above, but spacing characters in the East Asian + Ambiguous (A) category as defined in Unicode Technical Report #11 + have a column width of 2. + """ + if _bisearch(ucs, _AMBIGUOUS): + return 2 + else: + return wcwidth(ucs) + + +def wcswidth_cjk(pwcs): + """ As wcswidth above, but spacing characters in the East Asian + Ambiguous (A) category as defined in Unicode Technical Report #11 + have a column width of 2. + """ + width = 0 + for char in pwcs: + wcw = wcwidth_cjk(char) + if wcw < 0: + return -1 + else: + width += wcw + return width + + +##################################################################### +def _measure_string(ucs, length): + " helper function for padding " + tot = 0 + idx = 0 + while tot < length and idx < len(ucs): + tot += wcswidth(ucs[idx]) + idx += 1 + return (ucs[:idx], tot) + + +def rpadstring(ucs, length, padchar=' '): + """ Right-pad a Unicode string with padchar so that its width in + character cells is length. Padchar must be of width 1. The string + is truncated if it is too long.""" + pstr, plen = _measure_string(ucs, length) + if plen > length: + return pstr[:-1] + padchar + elif plen < length: + return pstr + (padchar * (length - plen)) + else: + return pstr + + +def truncatestring(ucs, length): + """ Truncate a Unicode string so that its length is as long as it + can be without exceeding length.""" + + pstr, plen = _measure_string(ucs, length) + + if plen > length: + return pstr[:-1] + else: + return pstr diff --git a/prompt_toolkit/line.py b/prompt_toolkit/line.py new file mode 100644 index 000000000..bf9ed88ad --- /dev/null +++ b/prompt_toolkit/line.py @@ -0,0 +1,987 @@ +""" +Data structures for the line input. +It holds the text, cursor position, history, etc... +""" +from __future__ import unicode_literals + +from functools import wraps + +from .code import Code, ValidationError +from .document import Document +from .enums import IncrementalSearchDirection, LineMode +from .prompt import Prompt +from .render_context import RenderContext +from .history import History + +import os +import tempfile +import subprocess + +__all__ = ( + 'Line', + + # Exceptions raised by the Line object. + 'Exit', + 'ReturnInput', + 'Abort', +) + +class Exit(Exception): + def __init__(self, render_context): + self.render_context = render_context + + +class ReturnInput(Exception): + def __init__(self, document, render_context): + self.document = document + self.render_context = render_context + + +class Abort(Exception): + def __init__(self, render_context): + self.render_context = render_context + + +class ClipboardDataType(object): + """ + Depending on how data has been copied, it can be pasted differently. + If a whole line is copied, it will always be inserted as a line (below or + above thu current one). If a word has been copied, it wiss be pasted + inline. So, if you copy a whole line, it will not be pasted in the middle + of another line. + """ + #: Several characters or words have been copied. They are pasted inline. + CHARACTERS = 'characters' + + #: A whole line that has been copied. This will be pasted below or above + #: the current line as a new line. + LINES = 'lines' + + +class ClipboardData(object): + """ + Text on the clipboard. + + :param text: string + :param type: :class:`~.ClipboardDataType` + """ + def __init__(self, text='', type=ClipboardDataType.CHARACTERS): + self.text = text + self.type = type + + +def _to_mode(*modes): + """ + When this method of the `Line` object is called. Make sure that we are in + the correct LineMode. (Quit reverse search / complete mode when + necessary.) + """ + def mode_decorator(func): + @wraps(func) + def wrapper(self, *a, **kw): + if self.mode not in modes: + if self.mode == LineMode.INCREMENTAL_SEARCH: + self.exit_isearch() + + elif self.mode == LineMode.COMPLETE: + self.mode = LineMode.NORMAL + + return func(self, *a, **kw) + return wrapper + return mode_decorator + + +class CompletionState(object): + def __init__(self, original_document, current_completions=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.current_completions = current_completions or [] + + #: Position in the `current_completions` array. + #: This can be `None` to indicate "no completion", the original text. + self.complete_index = 0 # Position in the `_completions` array. + + @property + def original_cursor_position(self): + self.original_document.cursor_position + + @property + def current_completion_text(self): + if self.complete_index is None: + return '' + else: + return self.current_completions[self.complete_index].suffix + +class _IncrementalSearchState(object): + def __init__(self, original_cursor_position, original_working_index): + self.isearch_direction = IncrementalSearchDirection.FORWARD + self.isearch_text = '' + + self.original_working_index = original_working_index + self.original_cursor_position = original_cursor_position + + +class Line(object): + """ + The core data structure that holds the text and cursor position of the + current input line and implements all text manupulations on top of it. It + also implements the history, undo stack, reverse search and the completion + state. + + :attr code_cls: :class:`~prompt_toolkit.code.CodeBase` class. + :attr prompt_cls: :class:`~prompt_toolkit.prompt.PromptBase` class. + :attr history: :class:`~prompt_toolkit.history.History` instance. + """ + #: Boolean to indicate whether we should consider this line a multiline input. + #: If so, the `InputStreamHandler` can decide to insert newlines when pressing [Enter]. + #: (Instead of accepting the input.) + is_multiline = False + + def __init__(self, renderer=None, code_cls=Code, prompt_cls=Prompt, history_cls=History): + self.renderer = renderer + self.code_cls = code_cls + self.prompt_cls = prompt_cls + + #: The command line history. + self._history = history_cls() + + self._clipboard = ClipboardData() + + self.__cursor_position = 0 + + #: Readline argument text (for displaying in the prompt.) + #: https://www.gnu.org/software/bash/manual/html_node/Readline-Arguments.html + self._arg_prompt_text = '' + + self.reset() + + def reset(self, initial_value=''): + self.mode = LineMode.NORMAL + self.cursor_position = len(initial_value) + + # `ValidationError` instance. (Will be set when the input is wrong.) + self.validation_error = None + + # State of Incremental-search + self.isearch_state = None + + # State of complete browser + self.complete_state = None + + # Undo stack + self._undo_stack = [] # Stack of (text, cursor_position) + + #: 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 = self._history.strings[:] + self._working_lines.append(initial_value) + self.__working_index = len(self._working_lines) - 1 + + ### + + @property + def text(self): + return self._working_lines[self._working_index] + + @text.setter + def text(self, value): + self._working_lines[self._working_index] = value + + # Always quit autocomplete mode when the text changes. + if self.mode == LineMode.COMPLETE: + self.mode = LineMode.NORMAL + + # Remove any validation errors. + self.validation_error = None + + self._text_changed() + + @property + def cursor_position(self): + return self.__cursor_position + + @cursor_position.setter + def cursor_position(self, value): + self.__cursor_position = max(0, value) + + # Always quit autocomplete mode when the cursor position changes. + if self.mode == LineMode.COMPLETE: + self.mode = LineMode.NORMAL + + # Remove any validation errors. + self.validation_error = None + + @property + def _working_index(self): + return self.__working_index + + @_working_index.setter + def _working_index(self, value): + # Always quit autocomplete mode when the working index changes. + if self.mode == LineMode.COMPLETE: + self.mode = LineMode.NORMAL + + self.__working_index = value + self._text_changed() + + ### End of + + def _text_changed(self): + """ + Not implemented. Override to capture when the current visible text + changes. + """ + pass + + def save_to_undo_stack(self): + """ + Safe current state (input text and cursor position), so that we can + restore it by calling undo. + """ + state = (self.text, self.cursor_position) + + # Safe if the text is different from the text at the top of the stack + # is different. + if not self._undo_stack or self._undo_stack[-1][0] != state[0]: + self._undo_stack.append(state) + + def set_current_line(self, value): + """ + Replace current line (Does not touch other lines in multi-line input.) + """ + # Move cursor to start of line. + self.cursor_to_start_of_line(after_whitespace=False) + + # Replace text + self.delete_until_end_of_line() + self.insert_text(value, move_cursor=False) + + def transform_lines(self, line_index_iterator, transform_callback): + """ + 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:: + + 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. + """ + # 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 + + self.text = '\n'.join(lines) + + @property + def document(self): + """ + Return :class:`.Document` instance from the current text and cursor + position. + """ + # TODO: this can be cached as long self.text does not change. + return Document(self.text, self.cursor_position) + + def set_arg_prompt(self, arg): + """ + Called from the `InputStreamHandler` to set a "(arg: x)"-like prompt. + (Both in Vi and Emacs-mode we have a way to repeat line operations. + Settings this attribute to the `Line` object allows the prompt/renderer + to visualise it.) + """ + self._arg_prompt_text = arg + + @_to_mode(LineMode.NORMAL) + def home(self): + self.cursor_position = 0 + + @_to_mode(LineMode.NORMAL) + def end(self): + self.cursor_position = len(self.text) + + @_to_mode(LineMode.NORMAL) + def cursor_left(self): + if self.document.cursor_position_col > 0: + self.cursor_position -= 1 + + @_to_mode(LineMode.NORMAL) + def cursor_right(self): + if not self.document.cursor_at_the_end_of_line: + self.cursor_position += 1 + + @_to_mode(LineMode.NORMAL) + def cursor_up(self): + """ + (for multiline edit). Move cursor to the previous line. + """ + new_pos = self.document.cursor_up_position + if new_pos is not None: + self.cursor_position = new_pos + + @_to_mode(LineMode.NORMAL) + def cursor_down(self): + """ + (for multiline edit). Move cursor to the next line. + """ + new_pos = self.document.cursor_down_position + if new_pos is not None: + self.cursor_position = new_pos + + @_to_mode(LineMode.NORMAL, LineMode.COMPLETE) + def auto_up(self): + """ + If we're not on the first line (of a multiline input) go a line up, + otherwise go back in history. + """ + if self.mode == LineMode.COMPLETE: + self.complete_previous() + elif self.document.cursor_position_row > 0: + self.cursor_up() + else: + self.history_backward() + + @_to_mode(LineMode.NORMAL, LineMode.COMPLETE) + def auto_down(self): + """ + If we're not on the last line (of a multiline input) go a line down, + otherwise go forward in history. + """ + if self.mode == LineMode.COMPLETE: + self.complete_next() + elif self.document.cursor_position_row < self.document.line_count - 1: + self.cursor_down() + else: + old_index = self._working_index + self.history_forward() + + # If we moved to the next line, place the cursor at the beginning. + if old_index != self._working_index: + self.cursor_position = 0 + + @_to_mode(LineMode.NORMAL) + def cursor_word_back(self): + """ Move the cursor to the start of the previous word. """ + # Move at least one character to the left. + self.cursor_position += (self.document.find_start_of_previous_word() or 0) + + @_to_mode(LineMode.NORMAL) + def cursor_word_forward(self): + """ Move the cursor to the start of the following word. """ + self.cursor_position += (self.document.find_next_word_beginning() or 0) + + @_to_mode(LineMode.NORMAL) + def cursor_to_end_of_word(self): + """ + Move the cursor right before the last character of the next word + ending. + """ + end = self.document.find_next_word_ending(include_current_position=False) + if end > 1: + self.cursor_position += end - 1 + + @_to_mode(LineMode.NORMAL) + def cursor_to_end_of_line(self): + """ + Move cursor to the end of the current line. + """ + self.cursor_position += len(self.document.current_line_after_cursor) + + @_to_mode(LineMode.NORMAL) + def cursor_to_start_of_line(self, after_whitespace=False): + """ Move the cursor to the first character of the current line. """ + self.cursor_position -= len(self.document.current_line_before_cursor) + + if after_whitespace: + text_after_cursor = self.document.current_line_after_cursor + self.cursor_position += len(text_after_cursor) - len(text_after_cursor.lstrip()) + + # NOTE: We can delete in i-search! + @_to_mode(LineMode.NORMAL, LineMode.INCREMENTAL_SEARCH) + def delete_character_before_cursor(self, count=1): # TODO: unittest return type + """ Delete character before cursor, return deleted character. """ + assert count > 0 + deleted = '' + + if self.mode == LineMode.INCREMENTAL_SEARCH: + self.isearch_state.isearch_text = self.isearch_state.isearch_text[:-count] + else: + if self.cursor_position > 0: + deleted = self.text[self.cursor_position - count:self.cursor_position] + self.text = self.text[:self.cursor_position - count] + self.text[self.cursor_position:] + self.cursor_position -= len(deleted) + + return deleted + + @_to_mode(LineMode.NORMAL) + def delete(self, count=1): # TODO: unittest `count` + """ Delete one character. Return deleted character. """ + 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 '' + + @_to_mode(LineMode.NORMAL) + def delete_word(self): + """ Delete one word. Return deleted word. """ + to_delete = self.document.find_next_word_beginning() + return self.delete(count=to_delete) + + @_to_mode(LineMode.NORMAL) + def delete_word_before_cursor(self): # TODO: unittest + """ Delete one word before cursor. Return deleted word. """ + to_delete = - (self.document.find_start_of_previous_word() or 0) + return self.delete_character_before_cursor(to_delete) + + @_to_mode(LineMode.NORMAL) + def delete_until_end(self): + """ Delete all input until the end. Return deleted text. """ + deleted = self.text[self.cursor_position:] + self.text = self.text[:self.cursor_position] + return deleted + + @_to_mode(LineMode.NORMAL) + def delete_until_end_of_line(self): # TODO: unittest. + """ + Delete all input until the end of this line. Return deleted text. + """ + to_delete = len(self.document.current_line_after_cursor) + return self.delete(count=to_delete) + + @_to_mode(LineMode.NORMAL) + def delete_from_start_of_line(self): # TODO: unittest. + """ + Delete all input from the start of the line until the current + character. Return deleted text. + (Actually, this is the same as pressing backspace until the start of + the line.) + """ + to_delete = len(self.document.current_line_before_cursor) + return self.delete_character_before_cursor(to_delete) + + @_to_mode(LineMode.NORMAL) + def delete_current_line(self): + """ + Delete current line. Return deleted text. + """ + document = self.document + + # Remember deleted text. + deleted = document.current_line + + # Cut line. + lines = document.lines + pos = document.cursor_position_row + self.text = '\n'.join(lines[:pos] + lines[pos+1:]) + + # Move cursor. + before_cursor = document.current_line_before_cursor + self.cursor_position -= len(before_cursor) + self.cursor_to_start_of_line(after_whitespace=True) + + return deleted + + @_to_mode(LineMode.NORMAL) + def join_next_line(self): + """ + Join the next line to the current one by deleting the line ending after + the current line. + """ + self.cursor_to_end_of_line() + self.delete() + + @_to_mode(LineMode.NORMAL) + def swap_characters_before_cursor(self): + """ + 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:] + + @_to_mode(LineMode.NORMAL) + def go_to_matching_bracket(self): + """ Go to matching [, (, { or < bracket. """ + stack = 1 + + for A, B in '()', '[]', '{}', '<>': + if self.document.current_char == A: + for i, c in enumerate(self.document.text_after_cursor[1:]): + if c == A: stack += 1 + elif c == B: stack -= 1 + + if stack == 0: + self.cursor_position += (i + 1) + break + + elif self.document.current_char == B: + for i, c in enumerate(reversed(self.document.text_before_cursor)): + if c == B: stack += 1 + elif c == A: stack -= 1 + + if stack == 0: + self.cursor_position -= (i + 1) + break + + @_to_mode(LineMode.NORMAL) + def go_to_substring(self, sub, in_current_line=False, backwards=False): + """ + Find next occurence of this substring, and move cursor position there. + """ + if backwards: + index = self.document.find_backwards(sub, in_current_line=in_current_line) + else: + index = self.document.find(sub, in_current_line=in_current_line) + + if index: + self.cursor_position += index + + @_to_mode(LineMode.NORMAL) + def go_to_column(self, column): + """ + Go to this column on the current line. (Go to the end column > length + of the line.) + """ + line_length = len(self.document.current_line) + current_column = self.document.cursor_position_col + column = max(0, min(line_length, column)) + + self.cursor_position += column - current_column + + def create_code_obj(self): + """ + Create `Code` instance from the current input. + """ + return self.code_cls(self.document) + + @_to_mode(LineMode.NORMAL) + def list_completions(self): + """ + Get and show all completions + """ + results = list(self.create_code_obj().get_completions()) + + if results and self.renderer: + self.renderer.render_completions(results) + + @_to_mode(LineMode.NORMAL) + def complete(self): # TODO: rename to complete_common + """ Autocomplete. + Returns true if there was a completion. """ + # On the first tab press, try to find one completion and complete. + result = self.create_code_obj().complete() # XXX: rename to get_common_completion() + if result: + self.text = self.text[:self.cursor_position] + result + self.text[self.cursor_position:] + self.cursor_position += len(result) + return True + else: + return False + + @_to_mode(LineMode.NORMAL, LineMode.COMPLETE) + def complete_next(self, count=1): + """ + Enter complete mode and browse through the completions. + """ + if not self.mode == LineMode.COMPLETE: + self._start_complete() + else: + completions_count = len(self.complete_state.current_completions) + + if self.complete_state.complete_index is None: + index = 0 + elif self.complete_state.complete_index == completions_count - 1: + index = None + else: + index = min(completions_count-1, self.complete_state.complete_index + count) + self._go_to_completion(index) + + @_to_mode(LineMode.NORMAL, LineMode.COMPLETE) + def complete_previous(self, count=1): + """ + Enter complete mode and browse through the completions. + """ + if not self.mode == LineMode.COMPLETE: + self._start_complete() + + if self.complete_state: + if self.complete_state.complete_index == 0: + index = None + elif self.complete_state.complete_index is None: + index = len(self.complete_state.current_completions) - 1 + else: + index = max(0, self.complete_state.complete_index - count) + + self._go_to_completion(index) + + def _start_complete(self): + """ + Start completions. (Generate list of completions and initialize.) + """ + # Generate list of all completions. + current_completions = list(self.create_code_obj().get_completions()) + + if current_completions: + self.complete_state = CompletionState( + original_document=self.document, + current_completions=current_completions) + self.insert_text(self.complete_state.current_completion_text) + self.mode = LineMode.COMPLETE + + else: + self.mode = LineMode.NORMAL + self.complete_state = None + + def _go_to_completion(self, index): + """ + Select a completion from the list of current completions. + """ + assert self.mode == LineMode.COMPLETE + + # Undo previous completion + count = len(self.complete_state.current_completion_text) + if count: + self.delete_character_before_cursor(count=len(self.complete_state.current_completion_text)) + + # Set new completion + self.complete_state.complete_index = index + self.insert_text(self.complete_state.current_completion_text) + + self.mode = LineMode.COMPLETE + + def get_render_context(self, _abort=False, _accept=False): + """ + Return a `RenderContext` object, to pass the current state to the renderer. + """ + if self.mode == LineMode.INCREMENTAL_SEARCH: + # In case of reverse search, render reverse search prompt. + code = self.code_cls(self.document) + + if self.document.has_match_at_current_position(self.isearch_state.isearch_text): + highlight_regions = [ + (self.document.translate_index_to_position(self.cursor_position), + self.document.translate_index_to_position(self.cursor_position + len(self.isearch_state.isearch_text))) ] + else: + highlight_regions = [ ] + + else: + code = self.create_code_obj() + highlight_regions = [ ] + + # Complete state + prompt = self.prompt_cls(self, code) + if self.mode == LineMode.COMPLETE and not _abort and not _accept: + complete_state = self.complete_state + else: + complete_state = None + + # Create prompt instance. + return RenderContext(prompt, code, highlight_regions=highlight_regions, + complete_state=complete_state, + abort=_abort, accept=_accept, + validation_error=self.validation_error) + + @_to_mode(LineMode.NORMAL) + def history_forward(self): + if self._working_index < len(self._working_lines) - 1: + # Go forward in history, and update cursor_position. + self._working_index += 1 + self.cursor_position = len(self.text) + + @_to_mode(LineMode.NORMAL) + def history_backward(self): + if self._working_index > 0: + # Go back in history, and update cursor_position. + self._working_index -= 1 + self.cursor_position = len(self.text) + + @_to_mode(LineMode.NORMAL) + def newline(self): + self.insert_text('\n') + + def insert_line_above(self, copy_margin=True): + """ + 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_to_start_of_line() + self.insert_text(insert) + self.cursor_position -= 1 + + def insert_line_below(self, copy_margin=True): + """ + 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_to_end_of_line() + self.insert_text(insert) + + def insert_text(self, data, overwrite=False, move_cursor=True): + """ + Insert characters at cursor position. + """ + if self.mode == LineMode.INCREMENTAL_SEARCH: + self.isearch_state.isearch_text += data + + if not self.document.has_match_at_current_position(self.isearch_state.isearch_text): + self.search_next(self.isearch_state.isearch_direction) + else: + # 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 = self.text[self.cursor_position:self.cursor_position+len(data)] + if '\n' in overwritten_text: + overwritten_text = overwritten_text[:overwritten_text.find('\n')] + + self.text = self.text[:self.cursor_position] + data + self.text[self.cursor_position+len(overwritten_text):] + else: + self.text = self.text[:self.cursor_position] + data + self.text[self.cursor_position:] + + if move_cursor: + self.cursor_position += len(data) + + def set_clipboard(self, clipboard_data): + """ + Set data to the clipboard. + + :param clipboard_data: :class:`~.ClipboardData` instance. + """ + self._clipboard = clipboard_data + + @_to_mode(LineMode.NORMAL) + def paste_from_clipboard(self, before=False): + """ + Insert the data from the clipboard. + """ + if self._clipboard and self._clipboard.text: + if self._clipboard.type == ClipboardDataType.CHARACTERS: + if before: + self.insert_text(self._clipboard.text) + else: + self.cursor_right() + self.insert_text(self._clipboard.text) + self.cursor_left() + + elif self._clipboard.type == ClipboardDataType.LINES: + if before: + self.cursor_to_start_of_line() + self.insert_text(self._clipboard.text + '\n', move_cursor=False) + else: + self.cursor_to_end_of_line() + self.insert_text('\n') + self.insert_text(self._clipboard.text, move_cursor=False) + + @_to_mode(LineMode.NORMAL) + def undo(self): + if self._undo_stack: + text, pos = self._undo_stack.pop() + + self.text = text + self.cursor_position = pos + + @_to_mode(LineMode.NORMAL) + def abort(self): + """ + Abort input. (Probably Ctrl-C press) + """ + render_context = self.get_render_context(_abort=True) + raise Abort(render_context) + + @_to_mode(LineMode.NORMAL) + def exit(self): + """ + Quit command line. (Probably Ctrl-D press.) + """ + render_context = self.get_render_context(_abort=True) + raise Exit(render_context) + + @_to_mode(LineMode.NORMAL) + def return_input(self): + """ + Return the current line to the `CommandLine.read_input` call. + """ + code = self.create_code_obj() + text = self.text + + # Validate first. If not valid, set validation exception. + try: + code.validate() + self.validation_error = None + except ValidationError as e: + # Set cursor position (don't allow invalid values.) + cursor_position = self.document.translate_row_col_to_index(e.line, e.column) + self.cursor_position = min(max(0, cursor_position), len(self.text)) + + self.validation_error = e + return + + # Save at the tail of the history. (But don't if the last entry the + # history is already the same.) + if not len(self._history) or self._history[-1] != text: + if text: + self._history.append(text) + + render_context = self.get_render_context(_accept=True) + + self.reset() + raise ReturnInput(code, render_context) + + @_to_mode(LineMode.NORMAL, LineMode.INCREMENTAL_SEARCH) + def reverse_search(self): + """ + Enter i-search mode, or if already entered, go to the previous match. + """ + direction = IncrementalSearchDirection.BACKWARD + + if self.mode == LineMode.INCREMENTAL_SEARCH: + self.search_next(direction) + else: + self._start_isearch(direction) + + @_to_mode(LineMode.NORMAL, LineMode.INCREMENTAL_SEARCH) + def forward_search(self): + """ + Enter i-search mode, or if already entered, go to the following match. + """ + direction = IncrementalSearchDirection.FORWARD + + if self.mode == LineMode.INCREMENTAL_SEARCH: + self.search_next(direction) + else: + self._start_isearch(direction) + + def _start_isearch(self, direction): + self.mode = LineMode.INCREMENTAL_SEARCH + self.isearch_state = _IncrementalSearchState( + original_cursor_position = self.cursor_position, + original_working_index = self._working_index) + self.isearch_state.isearch_direction = direction + + @_to_mode(LineMode.NORMAL, LineMode.INCREMENTAL_SEARCH) + def search_next(self, direction): + if not (self.mode == LineMode.INCREMENTAL_SEARCH and self.isearch_state.isearch_text): + return + + self.isearch_state.isearch_direction = direction + + isearch_text = self.isearch_state.isearch_text + + if direction == IncrementalSearchDirection.BACKWARD: + # Try find at the current input. + new_index = self.document.find_backwards(isearch_text) + + if new_index is not None: + self.cursor_position += new_index + else: + # No match, go back in the history. + for i in range(self._working_index - 1, -1, -1): + document = Document(self._working_lines[i], len(self._working_lines[i])) + new_index = document.find_backwards(isearch_text) + if new_index is not None: + self._working_index = i + self.cursor_position = len(self._working_lines[i]) + new_index + break + else: + # Try find at the current input. + new_index = self.document.find(isearch_text) + + if new_index is not None: + self.cursor_position += new_index + else: + # No match, go forward in the history. + for i in range(self._working_index + 1, len(self._working_lines)): + document = Document(self._working_lines[i], 0) + new_index = document.find(isearch_text, include_current_position=True) + if new_index is not None: + self._working_index = i + self.cursor_position = new_index + break + + def exit_isearch(self, restore_original_line=False): + """ + Exit i-search mode. + """ + if self.mode == LineMode.INCREMENTAL_SEARCH: + if restore_original_line: + self._working_index = self.isearch_state.original_working_index + self.cursor_position = self.isearch_state.original_cursor_position + + self.mode = LineMode.NORMAL + + @_to_mode(LineMode.NORMAL) + def clear(self): + """ + Clear renderer screen, usually as a result of Ctrl-L. + """ + if self.renderer: + self.renderer.clear() + + @_to_mode(LineMode.NORMAL) + def open_in_editor(self): + """ Open code in editor. """ + # Write to temporary file + descriptor, filename = tempfile.mkstemp() + os.write(descriptor, self.text.encode('utf-8')) + os.close(descriptor) + + # Open in editor + self._open_file_in_editor(filename) + + # Read content again. + with open(filename, 'rb') as f: + self.text = f.read().decode('utf-8') + self.cursor_position = len(self.text) + + # Clean up temp file. + os.remove(filename) + + def _open_file_in_editor(self, filename): + """ Call editor executable. """ + # If the 'EDITOR' environment variable has been set, use that one. + # Otherwise, fall back to the first available editor that we can find. + editor = os.environ.get('EDITOR') + + editors = [ + 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: + if os.path.exists(e): + subprocess.call([e, filename]) + return diff --git a/prompt_toolkit/prompt.py b/prompt_toolkit/prompt.py new file mode 100644 index 000000000..64ce0df48 --- /dev/null +++ b/prompt_toolkit/prompt.py @@ -0,0 +1,105 @@ +""" +Prompt representation. +""" +from __future__ import unicode_literals + +from pygments.token import Token +from .enums import IncrementalSearchDirection, LineMode + +__all__ = ( + 'PromptBase', + 'Prompt', +) + + +class PromptBase(object): + """ + Minimal base class for a valid prompt. + + :attr line: :class:`~prompt_toolkit.line.Line` instance. + :attr code: :class:`~prompt_toolkit.code.Code` instance. + """ + def __init__(self, line, code): + self.line = line + self.code = code + + def get_prompt(self): + """ + Text shown before the actual input. (The actual prompt.) + Generator of (Token, text) tuples. + """ + yield (Token.Prompt, '>') + + def get_second_line_prefix(self): + """ + When the renderer has to render the command line over several lines + because the input contains newline characters. This prefix will be + inserted before each line. + + This is a generator of (Token, text) tuples. + """ + # Take the length of the default prompt. + prompt_text = ''.join(p[1] for p in self.get_prompt()) + length = len(prompt_text.rstrip()) + spaces = len(prompt_text) - length + + yield (Token.Prompt.SecondLinePrefix, '.' * length) + yield (Token.Prompt.SecondLinePrefix, ' ' * spaces) + + def get_help_tokens(self): + """ + Return a list of (Token, text) tuples for the help text. + (This will be shown by the renderer after the text input, and can be + used to create a help text or a status line.) + """ + return [] + + +class Prompt(PromptBase): + """ + Default Prompt class + + :attr line: :class:`~prompt_toolkit.line.Line` instance. + :attr code: :class:`~prompt_toolkit.code.Code` instance. + """ + default_prompt_text = '> ' + + def get_prompt(self): + if self.line.mode == LineMode.INCREMENTAL_SEARCH: + return self.get_isearch_prompt() + elif self.line._arg_prompt_text: + return self.get_arg_prompt() + else: + return self.get_default_prompt() + + def get_default_prompt(self): + """ + Yield the tokens for the default prompt. + """ + yield (Token.Prompt, self.default_prompt_text) + + def get_arg_prompt(self): + """ + Yield the tokens for the arg-prompt. + """ + yield (Token.Prompt.Arg, '(arg: ') + yield (Token.Prompt.ArgText, str(self.line._arg_prompt_text)) + yield (Token.Prompt.Arg, ') ') + + def get_isearch_prompt(self): + """ + Yield the tokens for the prompt when we go in reverse-i-search mode. + """ + yield (Token.Prompt.ISearch.Bracket, '(') + + if self.line.isearch_state.isearch_direction == IncrementalSearchDirection.BACKWARD: + yield (Token.Prompt.ISearch, 'reverse-i-search') + else: + yield (Token.Prompt.ISearch, 'i-search') + + yield (Token.Prompt.ISearch.Bracket, ')') + + yield (Token.Prompt.ISearch.Backtick, '`') + yield (Token.Prompt.ISearch.Text, self.line.isearch_state.isearch_text) + yield (Token.Prompt.ISearch.Backtick, '`') + yield (Token.Prompt.ISearch.Backtick, ': ') diff --git a/prompt_toolkit/render_context.py b/prompt_toolkit/render_context.py new file mode 100644 index 000000000..648bf87d3 --- /dev/null +++ b/prompt_toolkit/render_context.py @@ -0,0 +1,23 @@ +from __future__ import unicode_literals + +class RenderContext(object): + """ + :attr prompt: :class:`~prompt_toolkit.prompt.PromptBase` instance. + :attr code_obj: :class:`~prompt_toolkit.code.Code` instance. + :param accept: True when the user accepts the input, by pressing enter. + (In that case we don't highlight the current line, and + set the mouse cursor at the end.) + :param abort: True after Ctrl-C abort. + :param highlight_regions: `None` or list of (start,len) tuples of the + characters to highlight. + """ + def __init__(self, prompt, code_obj, accept=False, abort=False, highlight_regions=None, complete_state=None, validation_error=None): + assert not (accept and abort) + + self.prompt = prompt + self.code_obj = code_obj + self.accept = accept + self.abort = abort + self.highlight_regions = highlight_regions + self.complete_state = complete_state + self.validation_error = validation_error diff --git a/prompt_toolkit/renderer.py b/prompt_toolkit/renderer.py new file mode 100644 index 000000000..87a7cc755 --- /dev/null +++ b/prompt_toolkit/renderer.py @@ -0,0 +1,513 @@ +""" +Renders the command line on the console. +(Redraws parts of the input line that were changed.) +""" +from __future__ import unicode_literals +import sys +import six + +from .utils import get_size +from .libs.wcwidth import wcwidth +from collections import defaultdict + +from pygments.formatters.terminal256 import Terminal256Formatter, EscapeSequence +from pygments.style import Style +from pygments.token import Token + +# Global variable to keep the colour table in memory. +_tf = Terminal256Formatter() + +__all__ = ( + 'RenderContext', + 'Renderer', +) + +class TerminalCodes: + """ + Escape codes for a VT100 terminal. + + For more info, see: http://www.termsys.demon.co.uk/vtansi.htm + """ + #: Erases the screen with the background colour and moves the cursor to home. + ERASE_SCREEN = '\x1b[2J' + + #: Erases from the current cursor position to the end of the current line. + ERASE_END_OF_LINE = '\x1b[K' + + #: Erases the screen from the current line down to the bottom of the screen. + ERASE_DOWN = '\x1b[J' + + CARRIAGE_RETURN = '\r' + NEWLINE = '\n' + CRLF = '\r\n' + + HIDE_CURSOR = '\x1b[?25l' + DISPLAY_CURSOR = '\x1b[?25h' + + @staticmethod + def CURSOR_GOTO(row=0, column=0): + """ Move cursor position. """ + return '\x1b[%i;%iH' % (row, column) + + @staticmethod + def CURSOR_UP(amount): + if amount == 1: + return '\x1b[A' + else: + return '\x1b[%iA' % amount + + @staticmethod + def CURSOR_DOWN(amount): + if amount == 1: + return '\x1b[B' + else: + return '\x1b[%iB' % amount + + @staticmethod + def CURSOR_FORWARD(amount): + if amount == 1: + return '\x1b[C' + else: + return '\x1b[%iC' % amount + + @staticmethod + def CURSOR_BACKWARD(amount): + if amount == 1: + return '\x1b[D' + else: + return '\x1b[%iD' % amount + + +class Char(object): + __slots__ = ('char', 'style') + + def __init__(self, char=' ', style=None): + self.char = char + self.style = style # TODO: maybe we should still use `token` instead of + # `style` and use the actual style in the last step of the renderer. + + def output(self): + """ Return the output to write this character to the terminal. """ + style = self.style + + if style: + e = EscapeSequence( + fg=(_tf._color_index(style['color']) if style['color'] else None), + bg=(_tf._color_index(style['bgcolor']) if style['bgcolor'] else None), + bold=style.get('bold', False), + underline=style.get('underline', False)) + + return ''.join([ + e.color_string(), + self.char, + e.reset_string() + ]) + else: + return self.char + + @property + def width(self): + # 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. + return max(0, wcwidth(self.char)) + + +class Screen(object): + """ + Two dimentional buffer for the output. + + :param style: Pygments style. + :param grayed: True when all tokes should be replaced by `Token.Aborted` + """ + def __init__(self, style, columns, grayed=False): + self._buffer = defaultdict(lambda: defaultdict(Char)) + self._cursor_mappings = { } # Map (row, col) of input data to (row, col) screen output. + self._x = 0 + self._y = 0 + + self._input_row = 0 + self._input_col = 0 + + self._columns = columns + self._style = style + self._grayed = grayed + self._second_line_prefix_func = None + + def save_input_pos(self): + self._cursor_mappings[self._input_row, self._input_col] = (self._y, self._x) + + def set_second_line_prefix(self, func): + """ + Set a function that returns a list of (token,text) tuples to be + inserted after every newline. + """ + self._second_line_prefix_func = func + + def write_char(self, char, token, is_input=True): + """ + Write char to current cursor position and move cursor. + """ + assert len(char) == 1 + + char_width = wcwidth(char) + + # In case of a double width character, if there is no more place left + # at this line, go first to the following line. + if self._x + char_width >= self._columns: + self._y += 1 + self._x = 0 + + # Remember at which position this input character has been drawn. + if is_input: + self.save_input_pos() + + # If grayed, replace token + if self._grayed: + token = Token.Aborted + + # Insertion of newline + if char == '\n': + self._y += 1 + self._x = 0 + + if is_input: + self._input_row += 1 + self._input_col = 0 + + if self._second_line_prefix_func: + self.write_highlighted(self._second_line_prefix_func(), is_input=False) + + # Insertion of a 'visible' character. + else: + self.write_at_pos(self._y, self._x, char, token) + + # Move cursor position + if is_input: + self._input_col += 1 + + if self._x + char_width >= self._columns: + self._y += 1 + self._x = 0 + else: + self._x += char_width + + def write_at_pos(self, y, x, char, token): + """ + Write character at position (y, x). + (Truncate when character is outside margin.) + """ + # Get style + try: + style = self._style.style_for_token(token) + except KeyError: + style = None + + # Add char to buffer + if y < self._columns: + self._buffer[y][x] = Char(char=char, style=style) + + def write_highlighted_at_pos(self, y, x, data): + """ + Write (Token, text) tuples at position (y, x). + (Truncate when character is outside margin.) + """ + for token, text in data: + for c in text: + self.write_at_pos(y, x, c, token) + x += wcwidth(c) + + def write_highlighted(self, data, is_input=True): + """ + Write (Token, text) tuples to the screen. + """ + for token, text in data: + for c in text: + self.write_char(c, token=token, is_input=is_input) + + def highlight_line(self, row, bgcolor='f8f8f8'): + for (y, x), (screen_y, screen_x) in self._cursor_mappings.items(): + if y == row: + self.highlight_character(y, x, bgcolor=bgcolor) + + def highlight_character(self, row, column, bgcolor=None, color=None): + """ + Highlight the character at row/column position. + (Row and column are input coordinates, not screen coordinates.) + """ + # We can only highlight this row/column when this position has been + # drawn to the screen. Only then we know the absolute position. + if (row, column) in self._cursor_mappings: + screen_y, screen_x = self._cursor_mappings[row, column] + + # Only highlight if we have this character in the buffer. + if screen_x in self._buffer[screen_y]: + c = self._buffer[screen_y][screen_x] + if c.style: + if bgcolor: c.style['bgcolor'] = bgcolor + if color: c.style['color'] = color + else: + c.style = { + 'bgcolor': bgcolor, + 'color': color, + } + + def output(self): + """ + Return (string, last_y, last_x) tuple. + """ + result = [] + + rows = max(self._buffer.keys()) + 1 + c = 0 + + for y, r in enumerate(range(0, rows)): + line_data = self._buffer[r] + if line_data: + cols = max(line_data.keys()) + 1 + + c = 0 + while c < cols: + result.append(line_data[c].output()) + c += (line_data[c].width or 1) + + if y != rows - 1: + result.append(TerminalCodes.CRLF) + + return ''.join(result), y, c + + +class _CompletionMenu(object): + """ + Helper for drawing the complete menu to the screen. + """ + def __init__(self, screen, complete_state, max_height=7): + self.screen = screen + self.complete_state = complete_state + self.max_height = max_height + + def _get_origin(self): + """ + Return the position of the menu. + We calculate this by mapping the cursor position (from the + complete_state) in the _cursor_mappings of the screen object. + """ + return self.screen._cursor_mappings[ + (self.complete_state.original_document.cursor_position_row, + self.complete_state.original_document.cursor_position_col)] + + def write(self): + """ + Write the menu to the screen object. + """ + completions = self.complete_state.current_completions + index = self.complete_state.complete_index # Can be None! + + # Get position of the menu. + y, x = self._get_origin() + y += 1 + x = max(0, x - 1) + + # Calculate width of completions menu. + menu_width = max(len(c.display) for c in self.complete_state.current_completions) + + # Decide which slice of completions to show. + if len(completions) > self.max_height and (index or 0) > self.max_height / 2: + slice_from = min( + (index or 0) - self.max_height // 2, # In the middle. + len(completions) - self.max_height # At the bottom. + ) + else: + slice_from = 0 + + slice_to = min(slice_from + self.max_height, len(completions)) + + # Create a function which decides at which positions the scroll button should be shown. + def is_scroll_button(row): + items_per_row = float(len(completions)) / min(len(completions), self.max_height) + items_on_this_row_from = row * items_per_row + items_on_this_row_to = (row + 1) * items_per_row + return items_on_this_row_from <= (index or 0) < items_on_this_row_to + + # Write completions to screen. + for i, c in enumerate(completions[slice_from:slice_to]): + if i + slice_from == index: + token = Token.CompletionMenu.CurrentCompletion + else: + token = Token.CompletionMenu.Completion + + if is_scroll_button(i): + button = (Token.CompletionMenu.ProgressButton, ' ') + else: + button = (Token.CompletionMenu.ProgressBar, ' ') + + self.screen.write_highlighted_at_pos(y+i, x, [ + (Token, ' '), + (token, ' %%-%is ' % menu_width % c.display), + button, + (Token, ' '), + ]) + + +class Renderer(object): + highlight_current_line = False + + #: Boolean to indicate whether or not the completion menu should be shown. + show_complete_menu = True + + screen_cls = Screen + + def __init__(self, stdout=None, style=None): + self._stdout = (stdout or sys.stdout) + self._style = style or Style + + # Reset position + self._cursor_line = 0 + + def get_width(self): + rows, cols = get_size(self._stdout.fileno()) + return cols + + def _get_new_screen(self, render_context): + """ + Create a `Screen` instance and draw all the characters on the screen. + """ + screen = self.screen_cls(style=self._style, columns=self.get_width(), grayed=render_context.abort) + + # Write prompt. + prompt_tuples = list(render_context.prompt.get_prompt()) + screen.write_highlighted(prompt_tuples, is_input=False) + + # Set second line prefix + second_line_prompt = list(render_context.prompt.get_second_line_prefix()) + screen.set_second_line_prefix(lambda: second_line_prompt) + + # Write code object. + screen.write_highlighted(render_context.code_obj.get_tokens()) + screen.save_input_pos() + + # Write help text. + screen.set_second_line_prefix(None) + if not (render_context.accept or render_context.abort): + help_tokens = render_context.prompt.get_help_tokens() + if help_tokens: + screen.write_highlighted(help_tokens) + + # Highlight current line. + if self.highlight_current_line and not (render_context.accept or render_context.abort): + screen.highlight_line(render_context.code_obj.document.cursor_position_row) + + # Highlight regions + if render_context.highlight_regions: + for (start_row, start_column), (end_row, end_column) in render_context.highlight_regions: + for i in range(start_column, end_column): + screen.highlight_character(start_row-1, i, bgcolor='444444', color='eeeeee') + + # Write completion menu. + if self.show_complete_menu and render_context.complete_state: + _CompletionMenu(screen, render_context.complete_state).write() + + return screen + + def _render_to_str(self, render_context): + output = [] + write = output.append + + # Move the cursor to the first line that was printed before + # and erase everything below it. + if self._cursor_line: + write(TerminalCodes.CURSOR_UP(self._cursor_line)) + + write(TerminalCodes.CARRIAGE_RETURN) + write(TerminalCodes.ERASE_DOWN) + + # Generate the output of the new screen. + screen = self._get_new_screen(render_context) + o, last_y, last_x = screen.output() + write(o) + + # Move cursor to correct position. + if render_context.accept or render_context.abort: + self._cursor_line = 0 + write(TerminalCodes.CRLF) + else: + cursor_y, cursor_x = screen._cursor_mappings[ + render_context.code_obj.document.cursor_position_row, + render_context.code_obj.document.cursor_position_col] + + if last_y - cursor_y: + write(TerminalCodes.CURSOR_UP(last_y - cursor_y)) + if last_x > cursor_x: + write(TerminalCodes.CURSOR_BACKWARD(last_x - cursor_x)) + if last_x < cursor_x: + write(TerminalCodes.CURSOR_FORWARD(cursor_x - last_x)) + + self._cursor_line = cursor_y + + return ''.join(output) + + def render(self, render_context): + out = self._render_to_str(render_context) + self._stdout.write(out) + self._stdout.flush() + + def render_completions(self, completions): + self._stdout.write(TerminalCodes.CRLF) + for line in self._in_columns([ c.display for c in completions ]): + self._stdout.write('%s\r\n' % line) + + # Reset position + self._cursor_line = 0 + + return + if many: # TODO: Implement paging + 'Display all %i possibilities? (y on n)' + + def clear(self): + """ + Clear screen and go to 0,0 + """ + self._stdout.write(TerminalCodes.ERASE_SCREEN) + self._stdout.write(TerminalCodes.CURSOR_GOTO(0, 0)) + + def _in_columns(self, item_iterator, margin_left=0): # XXX: copy of deployer.console.in_columns + """ + :param item_iterator: An iterable, which yields either ``basestring`` + instances, or (colored_item, length) tuples. + """ + # Helper functions for extracting items from the iterator + def get_length(item): + return len(item) if isinstance(item, six.string_types) else item[1] + + def get_text(item): + return item if isinstance(item, six.string_types) else item[0] + + # First, fetch all items + all_items = list(item_iterator) + + if not all_items: + return + + # Calculate the longest. + max_length = max(map(get_length, all_items)) + 1 + + # World per line? + term_width = self.get_width() - margin_left + words_per_line = int(max(term_width / max_length, 1)) + + # Iterate through items. + margin = ' ' * margin_left + line = [ margin ] + for i, j in enumerate(all_items): + # Print command and spaces + line.append(get_text(j)) + + # When we reached the max items on this line, yield line. + if (i+1) % words_per_line == 0: + yield ''.join(line) + line = [ margin ] + else: + # Pad with whitespace + line.append(' ' * (max_length - get_length(j))) + + yield ''.join(line) diff --git a/prompt_toolkit/utils.py b/prompt_toolkit/utils.py new file mode 100644 index 000000000..886a61c65 --- /dev/null +++ b/prompt_toolkit/utils.py @@ -0,0 +1,65 @@ +from __future__ import unicode_literals +import array +import fcntl +import signal +import termios +import tty +import six + + +def get_size(fileno): + # Thanks to fabric (fabfile.org), and + # http://sqizit.bartletts.id.au/2011/02/14/pseudo-terminals-in-python/ + """ + Get the size of this pseudo terminal. + + :param fileno: stdout.fileno() + :returns: A (rows, cols) tuple. + """ + #assert stdout.isatty() + + # Buffer for the C call + buf = array.array(u'h' if six.PY3 else b'h', [0, 0, 0, 0 ]) + + # Do TIOCGWINSZ (Get) + fcntl.ioctl(fileno, termios.TIOCGWINSZ, buf, True) +# fcntl.ioctl(0, termios.TIOCGWINSZ, buf, True) + + # Return rows, cols + return buf[0], buf[1] + + + +class raw_mode(object): + """ + with raw_mode(stdin): + ''' the pseudo-terminal stdin is now used in raw mode ''' + """ + def __init__(self, fileno): + self.fileno = fileno + self.attrs_before = termios.tcgetattr(fileno) + + def __enter__(self): + # NOTE: On os X systems, using pty.setraw() fails. Therefor we are using this: + newattr = termios.tcgetattr(self.fileno) + newattr[tty.LFLAG] = newattr[tty.LFLAG] & ~( + termios.ECHO | termios.ICANON | termios.IEXTEN | termios.ISIG) + termios.tcsetattr(self.fileno, termios.TCSANOW, newattr) + + def __exit__(self, *a, **kw): + termios.tcsetattr(self.fileno, termios.TCSANOW, self.attrs_before) + + +class call_on_sigwinch(object): + """ + Context manager which Installs a SIGWINCH callback. + (This signal occurs when the terminal size changes.) + """ + def __init__(self, callback): + self.callback = callback + + def __enter__(self): + self.previous_callback = signal.signal(signal.SIGWINCH, lambda *a:self.callback()) + + def __exit__(self, *a, **kw): + signal.signal(signal.SIGWINCH, self.previous_callback) diff --git a/setup.py b/setup.py new file mode 100644 index 000000000..1dc0c9d7f --- /dev/null +++ b/setup.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python +from setuptools import setup, find_packages + + +setup( + name='prompt_toolkit', + author='Jonathan Slenders', + version='0.5', + license='LICENSE.txt', + url='https://github.com/jonathanslenders/python-prompt-toolkit', + + description='', + long_description='', + packages=find_packages('.'), + install_requires = [ + 'pygments', 'docopt', 'six', + + # TODO: add wcwidth when released and stable on pypi + # 'wcwidth', + + # Required for the Python repl + 'jedi', + ], + scripts = [ + 'bin/ptpython', + ] +) diff --git a/tests.py b/tests.py new file mode 100644 index 000000000..2ea8d767b --- /dev/null +++ b/tests.py @@ -0,0 +1,621 @@ +#!/usr/bin/env python +from __future__ import unicode_literals + +import unittest +import six + +from prompt_toolkit.inputstream import InputStream +from prompt_toolkit.line import Line, Document, ReturnInput + + +class _CLILogger(object): + """ Dummy CLI class that records all the called methods. """ + def __init__(self): + self.log = [] + + def __call__(self, name, *a): + self.log.append((name,) + a) + + +class InputProtocolTest(unittest.TestCase): + def setUp(self): + self.cli = _CLILogger() + self.stream = InputStream(self.cli) + + def test_simple_feed_text(self): + self.stream.feed('test') + self.assertEqual(self.cli.log, [ + ('insert_char', 't'), + ('insert_char', 'e'), + ('insert_char', 's'), + ('insert_char', 't') + ]) + + def test_some_control_sequences(self): + self.stream.feed('t\x01e\x02s\x03t\x04\x05\x06') + self.assertEqual(self.cli.log, [ + ('insert_char', 't'), + ('ctrl_a', ), + ('insert_char', 'e'), + ('ctrl_b', ), + ('insert_char', 's'), + ('ctrl_c', ), + ('insert_char', 't'), + ('ctrl_d', ), + ('ctrl_e', ), + ('ctrl_f', ), + ]) + + def test_enter(self): + self.stream.feed('A\rB\nC\t') + self.assertEqual(self.cli.log, [ + ('insert_char', 'A'), + ('ctrl_m', ), + ('insert_char', 'B'), + ('ctrl_j', ), + ('insert_char', 'C'), + ('ctrl_i', ), + ]) + + def test_backspace(self): + self.stream.feed('A\x7f') + self.assertEqual(self.cli.log, [ + ('insert_char', 'A'), + ('backspace', ), + ]) + + def test_cursor_movement(self): + self.stream.feed('\x1b[AA\x1b[BB\x1b[CC\x1b[DD') + self.assertEqual(self.cli.log, [ + ('arrow_up',), + ('insert_char', 'A'), + ('arrow_down',), + ('insert_char', 'B'), + ('arrow_right',), + ('insert_char', 'C'), + ('arrow_left',), + ('insert_char', 'D'), + ]) + + def test_home_end(self): + self.stream.feed('\x1b[H\x1b[F') + self.stream.feed('\x1b[1~\x1b[4~') # tmux + self.stream.feed('\x1b[7~\x1b[8~') # xrvt + self.assertEqual(self.cli.log, [ + ('home',), ('end',), + ('home',), ('end',), + ('home',), ('end',), + ]) + + def test_page_up_down(self): + self.stream.feed('\x1b[5~\x1b[6~') + self.assertEqual(self.cli.log, [ + ('page_up',), + ('page_down',), + ]) + + def test_f_keys(self): + # F1 - F4 + self.stream.feed('\x1bOP') + self.stream.feed('\x1bOQ') + self.stream.feed('\x1bOR') + self.stream.feed('\x1bOS') + + # F5 - F10 + self.stream.feed('\x1b[15~') + self.stream.feed('\x1b[17~') + self.stream.feed('\x1b[18~') + self.stream.feed('\x1b[19~') + self.stream.feed('\x1b[20~') + self.stream.feed('\x1b[21~') + + self.assertEqual(self.cli.log, [ + ('F1',), ('F2',), ('F3',), ('F4',), + ('F5',), ('F6',), ('F7',), ('F8',), ('F9',), ('F10',), + ]) + + +class LineTest(unittest.TestCase): + def setUp(self): + self.cli = Line() + + def test_setup(self): + self.assertEqual(self.cli.text, '') + self.assertEqual(self.cli.cursor_position, 0) + + def test_insert_text(self): + self.cli.insert_text('some_text') + self.assertEqual(self.cli.text, 'some_text') + self.assertEqual(self.cli.cursor_position, len('some_text')) + + def test_cursor_movement(self): + self.cli.insert_text('some_text') + self.cli.cursor_left() + self.cli.cursor_left() + self.cli.cursor_left() + self.cli.cursor_right() + self.cli.insert_text('A') + + self.assertEqual(self.cli.text, 'some_teAxt') + self.assertEqual(self.cli.cursor_position, len('some_teA')) + + def test_home_end(self): + self.cli.insert_text('some_text') + self.cli.home() + self.cli.insert_text('A') + self.cli.end() + self.cli.insert_text('B') + self.assertEqual(self.cli.text, 'Asome_textB') + self.assertEqual(self.cli.cursor_position, len('Asome_textB')) + + def test_backspace(self): + self.cli.insert_text('some_text') + self.cli.cursor_left() + self.cli.cursor_left() + self.cli.delete_character_before_cursor() + + self.assertEqual(self.cli.text, 'some_txt') + self.assertEqual(self.cli.cursor_position, len('some_t')) + + def test_cursor_word_back(self): + self.cli.insert_text('hello world word3') + self.cli.cursor_word_back() + + self.assertEqual(self.cli.text, 'hello world word3') + self.assertEqual(self.cli.cursor_position, len('hello world ')) + + def test_cursor_to_start_of_line(self): + self.cli.insert_text('hello world\n line2\nline3') + self.assertEqual(self.cli.cursor_position, len('hello world\n line2\nline3')) + self.cli.cursor_position = len('hello world\n li') # Somewhere on the second line. + + self.cli.cursor_to_start_of_line() + self.assertEqual(self.cli.cursor_position, len('hello world\n')) + + self.cli.cursor_to_start_of_line(after_whitespace=True) + self.assertEqual(self.cli.cursor_position, len('hello world\n ')) + + def test_cursor_to_end_of_line(self): + self.cli.insert_text('hello world\n line2\nline3') + self.cli.cursor_position = 0 + + self.cli.cursor_to_end_of_line() + self.assertEqual(self.cli.cursor_position, len('hello world')) + + def test_cursor_word_forward(self): + self.cli.insert_text('hello world word3') + self.cli.home() + self.cli.cursor_word_forward() + + self.assertEqual(self.cli.text, 'hello world word3') + self.assertEqual(self.cli.cursor_position, len('hello ')) + + def test_cursor_to_end_of_word(self): + self.cli.insert_text('hello world') + self.cli.home() + + self.cli.cursor_to_end_of_word() + self.assertEqual(self.cli.cursor_position, len('hello') - 1) + + self.cli.cursor_to_end_of_word() + self.assertEqual(self.cli.cursor_position, len('hello world') - 1) + + def test_delete_word(self): + self.cli.insert_text('hello world word3') + self.cli.home() + self.cli.cursor_word_forward() + self.cli.delete_word() + + self.assertEqual(self.cli.text, 'hello word3') + self.assertEqual(self.cli.cursor_position, len('hello ')) + + def test_delete_until_end(self): + self.cli.insert_text('this is a sentence.') + self.cli.home() + self.cli.cursor_word_forward() + self.cli.delete_until_end() + + self.assertEqual(self.cli.text, 'this ') + self.assertEqual(self.cli.cursor_position, len('this ')) + + def test_delete_until_end_of_line(self): + self.cli.insert_text('line1\nline2\nline3') + self.cli.cursor_position = len('line1\nli') + + deleted_text = self.cli.delete_until_end_of_line() + + self.assertEqual(self.cli.text, 'line1\nli\nline3') + self.assertEqual(deleted_text, 'ne2') + + # If we only have one line. + self.cli.reset() + self.cli.insert_text('line1') + self.cli.cursor_position = 2 + + deleted_text = self.cli.delete_until_end_of_line() + + self.assertEqual(self.cli.text, 'li') + self.assertEqual(deleted_text, 'ne1') + + def test_cursor_up(self): + # Cursor up to a line thats longer. + self.cli.insert_text('long line1\nline2') + self.cli.cursor_up() + + self.assertEqual(self.cli.document.cursor_position, 5) + + # Going up when already at the top. + self.cli.cursor_up() + self.assertEqual(self.cli.document.cursor_position, 5) + + # Going up to a line that's shorter. + self.cli.reset() + self.cli.insert_text('line1\nlong line2') + + self.cli.cursor_up() + self.assertEqual(self.cli.document.cursor_position, 5) + + def test_cursor_down(self): + self.cli.insert_text('line1\nline2') + self.cli.cursor_position = 3 + + # Normally going down + self.cli.cursor_down() + self.assertEqual(self.cli.document.cursor_position, len('line1\nlin')) + + # Going down to a line that's storter. + self.cli.reset() + self.cli.insert_text('long line1\na\nb') + self.cli.cursor_position = 3 + + self.cli.cursor_down() + self.assertEqual(self.cli.document.cursor_position, len('long line1\na')) + + def test_auto_up_and_down(self): + self.cli.insert_text('line1\nline2') + with self.assertRaises(ReturnInput): + self.cli.return_input() + self.cli.insert_text('long line3\nlong line4') + + # Test current + self.assertEqual(self.cli.text, 'long line3\nlong line4') + self.assertEqual(self.cli.cursor_position, len('long line3\nlong line4')) + + # Go up. + self.cli.auto_up() + self.assertEqual(self.cli.text, 'long line3\nlong line4') + self.assertEqual(self.cli.cursor_position, len('long line3')) + + # Go up again (goes to first item.) + self.cli.auto_up() + self.assertEqual(self.cli.text, 'line1\nline2') + self.assertEqual(self.cli.cursor_position, len('line1\nline2')) + + # Go up again (goes to first line of first item.) + self.cli.auto_up() + self.assertEqual(self.cli.text, 'line1\nline2') + self.assertEqual(self.cli.cursor_position, len('line1')) + + # Go up again (while we're at the first item in history.) + # (Nothing changes.) + self.cli.auto_up() + self.assertEqual(self.cli.text, 'line1\nline2') + self.assertEqual(self.cli.cursor_position, len('line1')) + + # Go down (to second line of first item.) + self.cli.auto_down() + self.assertEqual(self.cli.text, 'line1\nline2') + self.assertEqual(self.cli.cursor_position, len('line1\nline2')) + + # Go down again (to first line of second item.) + # (Going down goes to the first character of a line.) + self.cli.auto_down() + self.assertEqual(self.cli.text, 'long line3\nlong line4') + self.assertEqual(self.cli.cursor_position, len('')) + + # Go down again (to second line of second item.) + self.cli.auto_down() + self.assertEqual(self.cli.text, 'long line3\nlong line4') + self.assertEqual(self.cli.cursor_position, len('long line3\n')) + + # Go down again after the last line. (nothing should happen.) + self.cli.auto_down() + self.assertEqual(self.cli.text, 'long line3\nlong line4') + self.assertEqual(self.cli.cursor_position, len('long line3\n')) + + def test_delete_current_line(self): + self.cli.insert_text('line1\nline2\nline3') + self.cli.cursor_up() + + deleted_text = self.cli.delete_current_line() + + self.assertEqual(self.cli.text, 'line1\nline3') + self.assertEqual(deleted_text, 'line2') + self.assertEqual(self.cli.cursor_position, len('line1\n')) + + def test_join_next_line(self): + self.cli.insert_text('line1\nline2\nline3') + self.cli.cursor_up() + self.cli.join_next_line() + + self.assertEqual(self.cli.text, 'line1\nline2line3') + + # Test when there is no '\n' in the text + self.cli.reset() + self.cli.insert_text('line1') + self.cli.cursor_position = 0 + self.cli.join_next_line() + + self.assertEqual(self.cli.text, 'line1') + + def test_go_to_matching_bracket(self): + self.cli.insert_text('A ( B [ C ) >') + self.cli.home() + self.cli.cursor_right() + self.cli.cursor_right() + + self.assertEqual(self.cli.cursor_position, 2) + self.cli.go_to_matching_bracket() + self.assertEqual(self.cli.cursor_position, 10) + self.cli.go_to_matching_bracket() + self.assertEqual(self.cli.cursor_position, 2) + + def test_newline(self): + self.cli.insert_text('hello world') + self.cli.newline() + + self.assertEqual(self.cli.text, 'hello world\n') + + def test_swap_characters_before_cursor(self): + self.cli.insert_text('hello world') + self.cli.cursor_left() + self.cli.cursor_left() + self.cli.swap_characters_before_cursor() + + self.assertEqual(self.cli.text, 'hello wrold') + + +class DocumentTest(unittest.TestCase): + def setUp(self): + self.document = Document( + 'line 1\n' + + 'line 2\n' + + 'line 3\n' + + 'line 4\n', + len( + 'line 1\n' + + 'lin') + ) + + def test_current_char(self): + self.assertEqual(self.document.current_char, 'e') + + def test_text_before_cursor(self): + self.assertEqual(self.document.text_before_cursor, 'line 1\nlin') + + def test_text_after_cursor(self): + self.assertEqual(self.document.text_after_cursor, + 'e 2\n' + + 'line 3\n' + + 'line 4\n') + + def test_lines(self): + self.assertEqual(self.document.lines, [ + 'line 1', + 'line 2', + 'line 3', + 'line 4', '' ]) + + def test_line_count(self): + self.assertEqual(self.document.line_count, 5) + + def test_current_line_before_cursor(self): + self.assertEqual(self.document.current_line_before_cursor, 'lin') + + def test_current_line_after_cursor(self): + self.assertEqual(self.document.current_line_after_cursor, 'e 2') + + def test_current_line(self): + self.assertEqual(self.document.current_line, 'line 2') + + def test_cursor_position(self): + self.assertEqual(self.document.cursor_position_row, 1) + self.assertEqual(self.document.cursor_position_col, 3) + + d = Document('', 0) + self.assertEqual(d.cursor_position_row, 0) + self.assertEqual(d.cursor_position_col, 0) + + def test_translate_index_to_position(self): + pos = self.document.translate_index_to_position( + len('line 1\nline 2\nlin')) + + self.assertEqual(pos[0], 3) + self.assertEqual(pos[1], 3) + + def test_cursor_at_end(self): + doc = Document('hello', 3) + self.assertEqual(doc.cursor_at_the_end, False) + + doc2 = Document('hello', 5) + self.assertEqual(doc2.cursor_at_the_end, True) + + +from prompt_toolkit.code import Code +from prompt_toolkit.prompt import Prompt + +import pygments + +class PromptTest(unittest.TestCase): + def setUp(self): + self.line = Line() + self.line.insert_text('some text') + + self.code = Code(self.line.document) + self.prompt = Prompt(self.line, self.code) + + def _test_token_text_list(self, data): + # Test whether data is list of (Token, text) tuples. + for token, text in data: + self.assertIsInstance(token, pygments.token._TokenType) + self.assertIsInstance(text, six.text_type) + + def test_get_prompt(self): + result = list(self.prompt.get_prompt()) + self._test_token_text_list(result) + + def test_second_line_prefix(self): + result = list(self.prompt.get_second_line_prefix()) + self._test_token_text_list(result) + + def test_get_help_tokens(self): + result = list(self.prompt.get_second_line_prefix()) + self._test_token_text_list(result) + + +#-- + + +from prompt_toolkit.contrib.shell.lexer import ParametersLexer, TextToken +from pygments.token import Token + +class ParameterLexerTest(unittest.TestCase): + def setUp(self): + self.lexer = ParametersLexer(stripnl=False, stripall=False, ensurenl=False) + + def test_simple(self): + t = list(self.lexer.get_tokens('aaa bbb ccc')) + self.assertEqual(t, [ + (Token.Text, 'aaa'), + (Token.WhiteSpace, ' '), + (Token.Text, 'bbb'), + (Token.WhiteSpace, ' '), + (Token.Text, 'ccc') ]) + + def test_complex(self): + t = list(self.lexer.get_tokens('''a'a 'a " b "bb ccc\\''')) + # The tokenizer separates text and whitespace, but keeps all the characters. + self.assertEqual(t, [ + (Token.Text, "a'a 'a"), + (Token.WhiteSpace, ' '), + (Token.Text, '" b "bb'), + (Token.WhiteSpace, ' '), + (Token.Text, 'ccc\\') ]) + + +class TextTokenTest(unittest.TestCase): + def test_simple(self): + t = TextToken('hello') + t.unescaped_text = 'hello' + + def test_double_quotes(self): + t = TextToken('h"e"llo" wor"ld') + self.assertEqual(t.unescaped_text, 'hello world') + self.assertEqual(t.inside_double_quotes, False) + self.assertEqual(t.inside_single_quotes, False) + self.assertEqual(t.trailing_backslash, False) + + def test_single_quotes(self): + t = TextToken("h'e'llo' wo'rld") + self.assertEqual(t.unescaped_text, 'hello world') + self.assertEqual(t.inside_double_quotes, False) + self.assertEqual(t.inside_single_quotes, False) + self.assertEqual(t.trailing_backslash, False) + + def test_backslashes(self): + t = TextToken("hello\ wo\\rld") + self.assertEqual(t.unescaped_text, 'hello world') + self.assertEqual(t.inside_double_quotes, False) + self.assertEqual(t.inside_single_quotes, False) + self.assertEqual(t.trailing_backslash, False) + + def test_open_double_quote(self): + t = TextToken('he"llo world') + self.assertEqual(t.unescaped_text, 'hello world') + self.assertEqual(t.inside_double_quotes, True) + self.assertEqual(t.inside_single_quotes, False) + self.assertEqual(t.trailing_backslash, False) + + def test_open_single_quote(self): + t = TextToken("he'llo world") + self.assertEqual(t.unescaped_text, 'hello world') + self.assertEqual(t.inside_double_quotes, False) + self.assertEqual(t.inside_single_quotes, True) + self.assertEqual(t.trailing_backslash, False) + + def test_trailing_backslash(self): + t = TextToken("hello\\ world\\") + self.assertEqual(t.unescaped_text, 'hello world') + self.assertEqual(t.inside_double_quotes, False) + self.assertEqual(t.inside_single_quotes, False) + self.assertEqual(t.trailing_backslash, True) + +#--- + +from prompt_toolkit.contrib.shell.rules import TokenStream + +class TokenStreamTest(unittest.TestCase): + def test_tokenstream(self): + s = TokenStream([ 'aaa', 'bbb', 'ccc', ]) + + # Test top + self.assertEqual(s.first_token, 'aaa') + self.assertEqual(s.has_more_tokens, True) + + # Pop + self.assertEqual(s.pop(), 'aaa') + self.assertEqual(s.first_token, 'bbb') + self.assertEqual(s.has_more_tokens, True) + + # Test restore point + with s.restore_point: + self.assertEqual(s.pop(), 'bbb') + self.assertEqual(s.first_token, 'ccc') + self.assertEqual(s.pop(), 'ccc') + + self.assertEqual(s.has_more_tokens, False) + self.assertEqual(s.first_token, None) + + # State should have been restored after the with block. + self.assertEqual(s.first_token, 'bbb') + self.assertEqual(s.has_more_tokens, True) + +#-- + +from prompt_toolkit.contrib.shell.rules import Literal +from prompt_toolkit.contrib.shell.nodes import LiteralNode + +class LiteralTest(unittest.TestCase): + def setUp(self): + self.literal = Literal('my-variable', dest='key') + + def test_literal_match(self): + stream = TokenStream([ 'my-variable' ]) + result = list(self.literal.parse(stream)) + + self.assertEqual(len(result), 1) + self.assertIsInstance(result[0], LiteralNode) + self.assertEqual(result[0].rule, self.literal) + self.assertEqual(result[0]._text, 'my-variable') + self.assertEqual(result[0].get_variables(), { 'key': 'my-variable' }) + + def test_literal_nomatch_suffix(self): + stream = TokenStream([ 'my-variable', 'suffix' ]) + result = list(self.literal.parse(stream)) + + self.assertEqual(len(result), 0) + + def test_literal_nomatch_invalid(self): + stream = TokenStream([ 'invalid' ]) + result = list(self.literal.parse(stream)) + + self.assertEqual(len(result), 0) + + +#class VariableTest(unittest.TestCase): +# def setUp(self): +# self.variable = Variable(placeholder='my-variable', dest='destination') + + +if __name__ == '__main__': + unittest.main()