diff --git a/old-bash-version/LICENSE b/old-bash-version/LICENSE deleted file mode 100644 index 8cdb845..0000000 --- a/old-bash-version/LICENSE +++ /dev/null @@ -1,340 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 2, June 1991 - - Copyright (C) 1989, 1991 Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The licenses for most software are designed to take away your -freedom to share and change it. By contrast, the GNU General Public -License is intended to guarantee your freedom to share and change free -software--to make sure the software is free for all its users. This -General Public License applies to most of the Free Software -Foundation's software and to any other program whose authors commit to -using it. (Some other Free Software Foundation software is covered by -the GNU Lesser General Public License instead.) You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -this service if you wish), that you receive source code or can get it -if you want it, that you can change the software or use pieces of it -in new free programs; and that you know you can do these things. - - To protect your rights, we need to make restrictions that forbid -anyone to deny you these rights or to ask you to surrender the rights. -These restrictions translate to certain responsibilities for you if you -distribute copies of the software, or if you modify it. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must give the recipients all the rights that -you have. You must make sure that they, too, receive or can get the -source code. And you must show them these terms so they know their -rights. - - We protect your rights with two steps: (1) copyright the software, and -(2) offer you this license which gives you legal permission to copy, -distribute and/or modify the software. - - Also, for each author's protection and ours, we want to make certain -that everyone understands that there is no warranty for this free -software. If the software is modified by someone else and passed on, we -want its recipients to know that what they have is not the original, so -that any problems introduced by others will not reflect on the original -authors' reputations. - - Finally, any free program is threatened constantly by software -patents. We wish to avoid the danger that redistributors of a free -program will individually obtain patent licenses, in effect making the -program proprietary. To prevent this, we have made it clear that any -patent must be licensed for everyone's free use or not licensed at all. - - The precise terms and conditions for copying, distribution and -modification follow. - - GNU GENERAL PUBLIC LICENSE - TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - - 0. This License applies to any program or other work which contains -a notice placed by the copyright holder saying it may be distributed -under the terms of this General Public License. The "Program", below, -refers to any such program or work, and a "work based on the Program" -means either the Program or any derivative work under copyright law: -that is to say, a work containing the Program or a portion of it, -either verbatim or with modifications and/or translated into another -language. (Hereinafter, translation is included without limitation in -the term "modification".) Each licensee is addressed as "you". - -Activities other than copying, distribution and modification are not -covered by this License; they are outside its scope. The act of -running the Program is not restricted, and the output from the Program -is covered only if its contents constitute a work based on the -Program (independent of having been made by running the Program). -Whether that is true depends on what the Program does. - - 1. You may copy and distribute verbatim copies of the Program's -source code as you receive it, in any medium, provided that you -conspicuously and appropriately publish on each copy an appropriate -copyright notice and disclaimer of warranty; keep intact all the -notices that refer to this License and to the absence of any warranty; -and give any other recipients of the Program a copy of this License -along with the Program. - -You may charge a fee for the physical act of transferring a copy, and -you may at your option offer warranty protection in exchange for a fee. - - 2. You may modify your copy or copies of the Program or any portion -of it, thus forming a work based on the Program, and copy and -distribute such modifications or work under the terms of Section 1 -above, provided that you also meet all of these conditions: - - a) You must cause the modified files to carry prominent notices - stating that you changed the files and the date of any change. - - b) You must cause any work that you distribute or publish, that in - whole or in part contains or is derived from the Program or any - part thereof, to be licensed as a whole at no charge to all third - parties under the terms of this License. - - c) If the modified program normally reads commands interactively - when run, you must cause it, when started running for such - interactive use in the most ordinary way, to print or display an - announcement including an appropriate copyright notice and a - notice that there is no warranty (or else, saying that you provide - a warranty) and that users may redistribute the program under - these conditions, and telling the user how to view a copy of this - License. (Exception: if the Program itself is interactive but - does not normally print such an announcement, your work based on - the Program is not required to print an announcement.) - -These requirements apply to the modified work as a whole. If -identifiable sections of that work are not derived from the Program, -and can be reasonably considered independent and separate works in -themselves, then this License, and its terms, do not apply to those -sections when you distribute them as separate works. But when you -distribute the same sections as part of a whole which is a work based -on the Program, the distribution of the whole must be on the terms of -this License, whose permissions for other licensees extend to the -entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest -your rights to work written entirely by you; rather, the intent is to -exercise the right to control the distribution of derivative or -collective works based on the Program. - -In addition, mere aggregation of another work not based on the Program -with the Program (or with a work based on the Program) on a volume of -a storage or distribution medium does not bring the other work under -the scope of this License. - - 3. You may copy and distribute the Program (or a work based on it, -under Section 2) in object code or executable form under the terms of -Sections 1 and 2 above provided that you also do one of the following: - - a) Accompany it with the complete corresponding machine-readable - source code, which must be distributed under the terms of Sections - 1 and 2 above on a medium customarily used for software interchange; or, - - b) Accompany it with a written offer, valid for at least three - years, to give any third party, for a charge no more than your - cost of physically performing source distribution, a complete - machine-readable copy of the corresponding source code, to be - distributed under the terms of Sections 1 and 2 above on a medium - customarily used for software interchange; or, - - c) Accompany it with the information you received as to the offer - to distribute corresponding source code. (This alternative is - allowed only for noncommercial distribution and only if you - received the program in object code or executable form with such - an offer, in accord with Subsection b above.) - -The source code for a work means the preferred form of the work for -making modifications to it. For an executable work, complete source -code means all the source code for all modules it contains, plus any -associated interface definition files, plus the scripts used to -control compilation and installation of the executable. However, as a -special exception, the source code distributed need not include -anything that is normally distributed (in either source or binary -form) with the major components (compiler, kernel, and so on) of the -operating system on which the executable runs, unless that component -itself accompanies the executable. - -If distribution of executable or object code is made by offering -access to copy from a designated place, then offering equivalent -access to copy the source code from the same place counts as -distribution of the source code, even though third parties are not -compelled to copy the source along with the object code. - - 4. You may not copy, modify, sublicense, or distribute the Program -except as expressly provided under this License. Any attempt -otherwise to copy, modify, sublicense or distribute the Program is -void, and will automatically terminate your rights under this License. -However, parties who have received copies, or rights, from you under -this License will not have their licenses terminated so long as such -parties remain in full compliance. - - 5. You are not required to accept this License, since you have not -signed it. However, nothing else grants you permission to modify or -distribute the Program or its derivative works. These actions are -prohibited by law if you do not accept this License. Therefore, by -modifying or distributing the Program (or any work based on the -Program), you indicate your acceptance of this License to do so, and -all its terms and conditions for copying, distributing or modifying -the Program or works based on it. - - 6. Each time you redistribute the Program (or any work based on the -Program), the recipient automatically receives a license from the -original licensor to copy, distribute or modify the Program subject to -these terms and conditions. You may not impose any further -restrictions on the recipients' exercise of the rights granted herein. -You are not responsible for enforcing compliance by third parties to -this License. - - 7. If, as a consequence of a court judgment or allegation of patent -infringement or for any other reason (not limited to patent issues), -conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot -distribute so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you -may not distribute the Program at all. For example, if a patent -license would not permit royalty-free redistribution of the Program by -all those who receive copies directly or indirectly through you, then -the only way you could satisfy both it and this License would be to -refrain entirely from distribution of the Program. - -If any portion of this section is held invalid or unenforceable under -any particular circumstance, the balance of the section is intended to -apply and the section as a whole is intended to apply in other -circumstances. - -It is not the purpose of this section to induce you to infringe any -patents or other property right claims or to contest validity of any -such claims; this section has the sole purpose of protecting the -integrity of the free software distribution system, which is -implemented by public license practices. Many people have made -generous contributions to the wide range of software distributed -through that system in reliance on consistent application of that -system; it is up to the author/donor to decide if he or she is willing -to distribute software through any other system and a licensee cannot -impose that choice. - -This section is intended to make thoroughly clear what is believed to -be a consequence of the rest of this License. - - 8. If the distribution and/or use of the Program is restricted in -certain countries either by patents or by copyrighted interfaces, the -original copyright holder who places the Program under this License -may add an explicit geographical distribution limitation excluding -those countries, so that distribution is permitted only in or among -countries not thus excluded. In such case, this License incorporates -the limitation as if written in the body of this License. - - 9. The Free Software Foundation may publish revised and/or new versions -of the General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - -Each version is given a distinguishing version number. If the Program -specifies a version number of this License which applies to it and "any -later version", you have the option of following the terms and conditions -either of that version or of any later version published by the Free -Software Foundation. If the Program does not specify a version number of -this License, you may choose any version ever published by the Free Software -Foundation. - - 10. If you wish to incorporate parts of the Program into other free -programs whose distribution conditions are different, write to the author -to ask for permission. For software which is copyrighted by the Free -Software Foundation, write to the Free Software Foundation; we sometimes -make exceptions for this. Our decision will be guided by the two goals -of preserving the free status of all derivatives of our free software and -of promoting the sharing and reuse of software generally. - - NO WARRANTY - - 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY -FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN -OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES -PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED -OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS -TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE -PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, -REPAIR OR CORRECTION. - - 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR -REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, -INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING -OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED -TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY -YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER -PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE -POSSIBILITY OF SUCH DAMAGES. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -convey the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - {description} - Copyright (C) {year} {fullname} - - This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along - with this program; if not, write to the Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -Also add information on how to contact you by electronic and paper mail. - -If the program is interactive, make it output a short notice like this -when it starts in an interactive mode: - - Gnomovision version 69, Copyright (C) year name of author - Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, the commands you use may -be called something other than `show w' and `show c'; they could even be -mouse-clicks or menu items--whatever suits your program. - -You should also get your employer (if you work as a programmer) or your -school, if any, to sign a "copyright disclaimer" for the program, if -necessary. Here is a sample; alter the names: - - Yoyodyne, Inc., hereby disclaims all copyright interest in the program - `Gnomovision' (which makes passes at compilers) written by James Hacker. - - {signature of Ty Coon}, 1 April 1989 - Ty Coon, President of Vice - -This General Public License does not permit incorporating your program into -proprietary programs. If your program is a subroutine library, you may -consider it more useful to permit linking proprietary applications with the -library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. - diff --git a/old-bash-version/README.md b/old-bash-version/README.md deleted file mode 100644 index 1044e71..0000000 --- a/old-bash-version/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# Shellmen -Shellmen is short for ShellMenu and is intended to be a functional menu for terminals. Rather than needing a full GUI menu for your programs you can view and launch your programs through Shellmen. This is great for systems that don't have panel menus or for systems that have a poorly organized right-click menu. - -# NOTE -This is the old and depricated bash version. It never really functioned to uts full potential. Please use the Python 3 version up a directory... - -# To Install -To install automatically please run the install.sh file and select option 2. -You will need to make it exacutable. -
-To Install manually: -
-sudo cp shellMen.sh /bin/
-sudo chown root:root /bin/shellMen
-sudo chmod 744 /bin/shellMen
-
-# To Uninstall -To uninstall automatically please run the install.sh file and select option 2. -
-To Uninstall manually: -
-sudo rm /bin/shellMen
-
-# License -You should have received a copy of the GNU General Public License along with this program. -
If not, see . - -# Images -![1 Root Menu View](images/pic1.png) -![2 Sub Menu View](images/pic2.png) diff --git a/old-bash-version/images/pic1.png b/old-bash-version/images/pic1.png deleted file mode 100644 index 0e56611..0000000 Binary files a/old-bash-version/images/pic1.png and /dev/null differ diff --git a/old-bash-version/images/pic2.png b/old-bash-version/images/pic2.png deleted file mode 100644 index 048e027..0000000 Binary files a/old-bash-version/images/pic2.png and /dev/null differ diff --git a/old-bash-version/install.sh b/old-bash-version/install.sh deleted file mode 100755 index aab3c95..0000000 --- a/old-bash-version/install.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash - -main() -{ -clear - - read -p "Please Press 1 to Install or 2 to Uninstall --> : " INPUT - if [[ "$INPUT" == 1 ]]; then - sudo cp ./shellMen /bin/ - sudo chown root:root /bin/shellMen - sudo chmod +x /bin/shellMen - elif [[ "$INPUT" == 2 ]]; then - sudo rm /bin/shellMen - elif [[ "$INPUT" != 1 || "$INPUT" != 2 ]] ; then - echo "Please type 1 or 2." - sleep 2 - main - fi -} -main diff --git a/old-bash-version/shellMen b/old-bash-version/shellMen deleted file mode 100755 index b579d21..0000000 --- a/old-bash-version/shellMen +++ /dev/null @@ -1,197 +0,0 @@ -#!/bin/bash -# -# By Maxim F. Stewart -# Contact: [1itdominator@gmail.com] -# -# Copyright 2013 Maxim F. Stewart -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -#---------------------------------------------------------------------------------------# - -declare -a menu=("Accessories" "Utility" "Multimedia" "Video" "Audio" - "Development" "Game" "Internet" "Network" "Graphics" - "Office" "System" "Settings" "Wine") - -mainMENU() { -exec 3>&1; # Custom stream to set variable from dialog - -INPUT=$(dialog --clear --backtitle "Shellmen" \ ---title "[ M A I N - M E N U ]" \ ---menu "Please Select An Option" 16 50 15 \ -Accessories "General Programs" \ -System "Main System Programs" \ -Settings "Main System Settings" \ -Multimedia "Audio & Video Programs" \ -Graphics "Image Programs" \ -Games "Gaming Programs" \ -Office "Wordprocess & Documents Programs" \ -Development "Programing Programs" \ -Internet "Various Internet Related Programs" \ -Wine "Windows Exe & Program Support" 2>&1 1>&3 ) - -case $INPUT in - Accessories) bash /tmp/sysMENU/Accessories.sh ;; - System) bash /tmp/sysMENU/System.sh ;; - Settings) bash /tmp/sysMENU/Settings.sh ;; - Multimedia) bash /tmp/sysMENU/Multimedia.sh ;; - Graphics) bash /tmp/sysMENU/Graphics.sh ;; - Games) bash /tmp/sysMENU/Game.sh ;; - Office) bash /tmp/sysMENU/Office.sh ;; - Development) bash /tmp/sysMENU/Development.sh ;; - Internet) bash /tmp/sysMENU/Internet.sh ;; - Wine) bash /tmp/sysMENU/Wine.sh ;; - Exit) echo "Bye!"; break ;; -esac -} - -commandInsert() { -x=$(cat /tmp/sysMENU/menu.list | wc -l) >> /dev/null ; -i="1" - -while [ $i -le $x ]; do - line1=$(sed -n "${i}p" /tmp/sysMENU/menu.list); - filename="${line1%.*}" - execMethod=$(grep -A 0 "Exec=" /usr/share/applications/"$line1") - catagory=$(grep -A 0 "Categories=" /usr/share/applications/"$line1") - preComment=$(grep -A 0 "Comment=" /usr/share/applications/"$line1") - execCMD=$(echo "${filename}) exec ${filename} ;;") - - writeToPath "$catagory" "$execCMD" - -i=$[$i++1]; -done - -for opt in "${menu[@]}"; do - if [[ $opt == "${menu[8]}" ]]; then - opt=${menu[7]} - fi - - if [[ $opt == "${menu[3]}" || $opt == "${menu[4]}" ]]; then - opt=${menu[2]} - fi - echo "esac" >> /tmp/sysMENU/"${opt}".sh -done - -chmod +x /tmp/sysMENU/*.sh -mainMENU; -} - -menuHeaderInsert() { -x=$(cat /tmp/sysMENU/menu.list | wc -l) >> /dev/null ; # Variable set to number of lines filled in list.txt -i="1" - -while [ $i -le $x ]; do - line1=$(sed -n "${i}p" /tmp/sysMENU/menu.list); # Reads the number of lines in list.txt then sets as a variable counting up to variable x - filename="${line1%.*}" - execMethod=$(grep -A 0 "Exec=" /usr/share/applications/"$line1") - catagory=$(grep -A 0 "Categories=" /usr/share/applications/"$line1") - preComment=$(grep -A 0 "Comment=" /usr/share/applications/"$line1") - comment=$(sed s/"Comment="//g <<< ${preComment}) - inputer=$(echo "$filename "\"$comment"\" \\") - - writeToPath "$catagory" "$inputer" - -i=$[$i++1]; -done - -endMenuInsert=$(echo "2>"\"'${INPUT}'"\"") -menuitmVar=$(echo 'menuitem=$(<"${INPUT}")') -preCMD=$(echo "case \$menuitem in") -menuCall=$(echo "Main_Menu) bash /bin/shellMen ;;") - - -for opt in "${menu[@]}"; do - if [[ $opt == "${menu[8]}" ]]; then - opt=${menu[7]} - fi - - if [[ $opt == "${menu[3]}" || $opt == "${menu[4]}" ]]; then - opt=${menu[2]} - fi - - echo "$endMenuInsert" >> /tmp/sysMENU/"${opt}".sh - echo "$menuitmVar" >> /tmp/sysMENU/"${opt}".sh - echo "$preCMD" >> /tmp/sysMENU/"${opt}".sh - echo "$menuCall" >> /tmp/sysMENU/"${opt}".sh -done - -commandInsert; -} - -function writeToPath() { - catagory=$1 - inputer=$2 - - - if [[ "$catagory" == *"${menu[0]}"* ]] || [[ "$catagory" == *"${menu[1]}"* ]]; then - echo "$inputer" >> /tmp/sysMENU/"${menu[0]}".sh - elif [[ "$catagory" == *"${menu[2]}*" ]] \ - || [[ "$catagory" == *"${menu[3]}"* ]] \ - || [[ "$catagory" == *"${menu[4]}"* ]]; then - echo "$inputer" >> /tmp/sysMENU/"${menu[2]}".sh - elif [[ "$catagory" == *"${menu[5]}"* ]]; then - echo "$inputer" >> /tmp/sysMENU/"${menu[5]}".sh - elif [[ "$catagory" == *"${menu[6]}"* ]]; then - echo "$inputer" >> /tmp/sysMENU/"${menu[6]}".sh - elif [[ "$catagory" == *"${menu[7]}"* ]] || [[ "$catagory" == *"${menu[8]}"* ]] ; then - echo "$inputer" >> /tmp/sysMENU/"${menu[7]}".sh - elif [[ "$catagory" == *"${menu[9]}"* ]]; then - echo "$inputer" >> /tmp/sysMENU/"${menu[9]}".sh - elif [[ "$catagory" == *"${menu[10]}"* ]]; then - echo "$inputer" >> /tmp/sysMENU/"${menu[10]}".sh - elif [[ "$catagory" == *"${menu[11]}"* ]]; then - echo "$inputer" >> /tmp/sysMENU/"${menu[11]}".sh - elif [[ "$catagory" == *"${menu[12]}"* ]]; then - echo "$inputer" >> /tmp/sysMENU/"${menu[12]}".sh - elif [[ "$catagory" == *"${menu[13]}"* ]]; then - echo "$inputer" >> /tmp/sysMENU/"${menu[13]}".sh - fi -} - - -startScan() { -clear -mkdir /tmp/sysMENU -touch /tmp/sysMENU/menu.list ; -sed -i "d" /tmp/sysMENU/menu.list ; -ls -p /usr/share/applications/ | grep -v / >> /tmp/sysMENU/menu.list ; - -header='''#!/bin/bash -INPUT=/tmp/menu.txt -dialog --clear --backtitle "Shellmen" \ ---title "[ S U B - M E N U ]" \ ---menu "Please Select An Option" 15 50 10 \ -Main_Menu "Goes To Main Menu" \''' - -for opt in "${menu[@]}"; do - if [[ $opt == "${menu[8]}" ]]; then - opt=${menu[7]} - fi - - if [[ $opt == "${menu[3]}" || $opt == "${menu[4]}" ]]; then - opt=${menu[2]} - fi - echo "$header" > /tmp/sysMENU/"${opt}".sh -done - -menuHeaderInsert; -} - -pre() { - if [ -d /tmp/sysMENU/ ]; then - mainMENU; - else - startScan; - fi -} -pre; diff --git a/src/__builtins__.py b/src/__builtins__.py index 913686d..d2efe07 100644 --- a/src/__builtins__.py +++ b/src/__builtins__.py @@ -1,19 +1,46 @@ # Python imports import builtins +import threading # Lib imports # Application imports +from utils.event_system import EventSystem +from utils.logger import Logger +from utils.settings_manager.manager import SettingsManager -class Builtins: - def dummy(self): - pass +class BuiltinsException(Exception): + ... + + +# NOTE: Threads WILL NOT die with parent's destruction. +def threaded_wrapper(fn): + def wrapper(*args, **kwargs): + threading.Thread(target=fn, args=args, kwargs=kwargs, daemon=False).start() + return wrapper + +# NOTE: Threads WILL die with parent's destruction. +def daemon_threaded_wrapper(fn): + def wrapper(*args, **kwargs): + threading.Thread(target=fn, args=args, kwargs=kwargs, daemon=True).start() + return wrapper # NOTE: Just reminding myself we can add to builtins two different ways... # __builtins__.update({"event_system": Builtins()}) builtins.app_name = "Shellmen" -builtins.debug = False -builtins.trace_debug = False +builtins.event_system = EventSystem() +builtins.settings_manager = SettingsManager() + +settings_manager.load_settings() + +builtins.settings = settings_manager.settings +builtins.logger = Logger(settings_manager.get_home_config_path(), \ + _ch_log_lvl=settings.debugging.ch_log_lvl, \ + _fh_log_lvl=settings.debugging.fh_log_lvl).get_logger() + +builtins.threaded = threaded_wrapper +builtins.daemon_threaded = daemon_threaded_wrapper +builtins.event_sleep_time = 0.05 diff --git a/src/__init__.py b/src/__init__.py index 209ef30..90dc8da 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,20 +1,3 @@ -# Python imports -import os, inspect, time - -# Lib imports - -# Application imports -from utils import Settings -from signal_classes import Controller -from __builtins__ import Builtins - - - - -class Main(Builtins): - def __init__(self, args, unknownargs): - settings = Settings() - controller = Controller(settings, args, unknownargs) - - if not controller: - raise Exception("Controller exited and doesn't exist...") +""" + Start of package. +""" diff --git a/src/__main__.py b/src/__main__.py index b2e005f..ace645c 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -1,36 +1,43 @@ #!/usr/bin/python3 - # Python imports -import argparse, faulthandler, traceback +import argparse +import faulthandler +import traceback from setproctitle import setproctitle -import tracemalloc -tracemalloc.start() - - # Lib imports - # Application imports -from __init__ import Main +from __builtins__ import * +from app import Application + + if __name__ == "__main__": - try: - # import web_pdb - # web_pdb.set_trace() + ''' Set process title, get arguments, and create GTK main thread. ''' - setproctitle('Shellmen') + try: + setproctitle(f'{app_name}') faulthandler.enable() # For better debug info + parser = argparse.ArgumentParser() # Add long and short arguments - parser.add_argument("--theme", "-t", default="default", help="The theme to use for the menu. (default, orange, red, purple, green)") + parser.add_argument("--theme", "-t", default="default", help="Set the theme. Options [orange, red, purple, green].") + parser.add_argument("--debug", "-d", default="false", help="Do extra console messaging.") + parser.add_argument("--trace-debug", "-td", default="false", help="Disable saves, ignore IPC lock, do extra console messaging.") # Read arguments (If any...) args, unknownargs = parser.parse_known_args() - Main(args, unknownargs) + if args.debug == "true": + settings_manager.set_debug(True) + + if args.trace_debug == "true": + settings_manager.set_trace_debug(True) + + Application(args, unknownargs) except Exception as e: traceback.print_exc() quit() diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..9325654 --- /dev/null +++ b/src/app.py @@ -0,0 +1,48 @@ +# Python imports +import signal +import os + +# Lib imports + +# Application imports +from utils.debugging import debug_signal_handler +from utils.ipc_server import IPCServer +from core.window import Window + + + +class AppLaunchException(Exception): + ... + + +class Application(IPCServer): + """ docstring for Application. """ + + def __init__(self, args, unknownargs): + super(Application, self).__init__() + + if not settings_manager.is_trace_debug(): + try: + self.create_ipc_listener() + except Exception: + ... + + if not self.is_ipc_alive: + for arg in unknownargs + [args.new_tab,]: + if os.path.isfile(arg): + message = f"FILE|{arg}" + self.send_ipc_message(message) + + raise AppLaunchException(f"{app_name} IPC Server Exists: Will send path(s) to it and close...") + + try: + # kill -SIGUSR2 from Linux/Unix or SIGBREAK signal from Windows + signal.signal( + vars(signal).get("SIGBREAK") or vars(signal).get("SIGUSR1"), + debug_signal_handler + ) + except ValueError: + # Typically: ValueError: signal only works in main thread + ... + + Window(args, unknownargs) diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..90cfadc --- /dev/null +++ b/src/core/__init__.py @@ -0,0 +1,3 @@ +""" + Gtk Bound Signal Module +""" diff --git a/src/core/controller.py b/src/core/controller.py new file mode 100644 index 0000000..6c307fd --- /dev/null +++ b/src/core/controller.py @@ -0,0 +1,35 @@ +# Python imports + +# Lib imports + +# Application imports +from .mixins.processor_mixin import ProcessorMixin +from .controller_data import ControllerData +from .widgets.desktop_files import DdesktopFiles +from .widgets.menu import Menu + + + +class Controller(ProcessorMixin, ControllerData): + def __init__(self, args, unknownargs): + self.setup_controller_data() + + self._setup_styling() + self._setup_signals() + self._subscribe_to_events() + self._load_widgets(args, unknownargs) + + + def _setup_styling(self): + ... + + def _setup_signals(self): + ... + + def _subscribe_to_events(self): + event_system.subscribe("execute_program", self.execute_program) + event_system.subscribe("clear_console", self.clear_console) + + def _load_widgets(self, args, unknownargs): + DdesktopFiles() + Menu(args, unknownargs) diff --git a/src/core/controller_data.py b/src/core/controller_data.py new file mode 100644 index 0000000..f744298 --- /dev/null +++ b/src/core/controller_data.py @@ -0,0 +1,51 @@ +# Python imports +import os +import subprocess + +# Lib imports + +# Application imports + + +class ControllerData: + ''' ControllerData contains most of the state of the app at ay given time. It also has some support methods. ''' + + def setup_controller_data(self) -> None: + ... + + def clear_console(self) -> None: + ''' Clears the terminal screen. ''' + os.system('cls' if os.name == 'nt' else 'clear') + + def call_method(self, _method_name: str, data: type) -> type: + ''' + Calls a method from scope of class. + + Parameters: + a (obj): self + b (str): method name to be called + c (*): Data (if any) to be passed to the method. + Note: It must be structured according to the given methods requirements. + + Returns: + Return data is that which the calling method gives. + ''' + method_name = str(_method_name) + method = getattr(self, method_name, lambda data: f"No valid key passed...\nkey={method_name}\nargs={data}") + return method(*data) if data else method() + + def has_method(self, obj: type, method: type) -> type: + ''' Checks if a given method exists. ''' + return callable(getattr(obj, method, None)) + + def get_clipboard_data(self, encoding="utf-8") -> str: + proc = subprocess.Popen(['xclip','-selection', 'clipboard', '-o'], stdout=subprocess.PIPE) + retcode = proc.wait() + data = proc.stdout.read() + return data.decode(encoding).strip() + + def set_clipboard_data(self, data: type, encoding="utf-8") -> None: + proc = subprocess.Popen(['xclip','-selection','clipboard'], stdin=subprocess.PIPE) + proc.stdin.write(data.encode(encoding)) + proc.stdin.close() + retcode = proc.wait() diff --git a/src/core/mixins/__init__.py b/src/core/mixins/__init__.py new file mode 100644 index 0000000..4589fc7 --- /dev/null +++ b/src/core/mixins/__init__.py @@ -0,0 +1,3 @@ +""" + Generic Mixins Module +""" diff --git a/src/core/mixins/processor_mixin.py b/src/core/mixins/processor_mixin.py new file mode 100644 index 0000000..394fd0e --- /dev/null +++ b/src/core/mixins/processor_mixin.py @@ -0,0 +1,35 @@ +# Python imports +import os, subprocess + +# Lib imports + +# Application imports + + + +class ProcessorMixin: + def execute_program(self, exec_ops): + parts = exec_ops.split("||") + try_exec = parts[0].strip() + main_exec = parts[1].strip() + + self.pre_execute(try_exec, main_exec) + + def pre_execute(self, try_exec, main_exec): + try: + return self.execute(try_exec) + except Exception as e: + logger.debug(f"[Executing Program]\n\t\t Try Exec failed!\n{repr(e)}") + + try: + return self.execute(main_exec) + except Exception as e: + logger.debug(f"[Executing Program]\n\t\t Main Exec failed!\n{repr(e)}") + + + def execute(self, option): + DEVNULL = open(os.devnull, 'w') + command = option.split("%")[0] + + logger.debug(f"Command: {command}") + subprocess.Popen(command.split(), cwd=os.getenv("HOME"), start_new_session=True, stdout=DEVNULL, stderr=DEVNULL) diff --git a/src/core/widgets/__init__.py b/src/core/widgets/__init__.py new file mode 100644 index 0000000..72b072b --- /dev/null +++ b/src/core/widgets/__init__.py @@ -0,0 +1,3 @@ +""" + Widgets Module +""" diff --git a/src/core/widgets/desktop_files.py b/src/core/widgets/desktop_files.py new file mode 100644 index 0000000..8c05a10 --- /dev/null +++ b/src/core/widgets/desktop_files.py @@ -0,0 +1,146 @@ +# Python imports +import pickle +from os import listdir +from dataclasses import fields + +# Lib imports +from xdg.DesktopEntry import DesktopEntry + +# Application imports + + + +class DdesktopFiles: + def __init__(self): + + self.application_dirs = settings.config.application_dirs + self.desktop_enteries = [] + self.groups = {} + + self._setup_styling() + self._setup_signals() + self._subscribe_to_events() + self.reload_desktop_entries() + self.create_groups_mapping() + + + def _setup_styling(self): + ... + + def _setup_signals(self): + ... + + def _subscribe_to_events(self): + event_system.subscribe("reload_desktop_entries", self.reload_desktop_entries) + event_system.subscribe("get_desktop_entries", self.get_desktop_entries) + event_system.subscribe("get_search_results", self.get_search_results) + event_system.subscribe("get_favorites_results", self.get_favorites_results) + event_system.subscribe("get_sub_group", self.get_sub_group) + + + def reload_desktop_entries(self): + self.desktop_enteries.clear() + self.desktop_enteries = None + self.desktop_enteries = [] + self.collect_desktop_entries() + + self.groups = None + self.groups = {} + self.create_groups_mapping() + + def collect_desktop_entries(self): + for path in self.application_dirs: + for f in listdir(path): + if f.endswith(".desktop"): + self.create_desktop_entry(f"{path}/{f}") + + def create_desktop_entry(self, path): + xdg_object = DesktopEntry(path) + + if xdg_object.getHidden() or xdg_object.getNoDisplay(): + return + + type = xdg_object.getType() + if type == "Application": + self.desktop_enteries.append(xdg_object) + + def create_groups_mapping(self): + self.create_default_groups() + self.generation_primary_group_mapping() + self.cross_append_groups() + + def create_default_groups(self): + for slot in settings.filters.__slots__: + self.groups[slot.title()] = [] + + def generation_primary_group_mapping(self): + for entry in self.desktop_enteries: + groups = entry.getCategories() + if not groups: + self.groups["Other"].append(entry) + + for group in groups: + if not group in self.groups.keys(): + self.groups[group] = [] + + self.groups[group].append(entry) + + def cross_append_groups(self): + fields_data = fields(settings.filters) + for field in fields_data: + title = field.name.title() + to_merge = [] + + for group in field.default_factory(): + to_merge += self.groups[group] + + sub_map = {} + # NOTE: Will "hash" filters ("to_merge" var) first so that target self.groups[title] overrites with its own if any entry exists. + for entry in to_merge + self.groups[title]: + s1 = pickle.dumps(entry) + str_version = s1.decode('unicode_escape') + sub_map[str_version] = entry + + merged_set = [] + for key in sub_map.keys(): + merged_set.append(sub_map[key]) + + self.groups[title] = merged_set + + def get_desktop_entries(self) -> []: + return self.desktop_enteries + + def get_favorites_results(self, group): + results = [] + + for entry in self.desktop_enteries: + _entry = f"{entry.getName()} || {entry.getComment()}" + if _entry in settings.favorites["apps"]: + try_exec = entry.getTryExec() + main_exec = entry.getExec() + results.append( [_entry, f" {try_exec} || {main_exec}"] ) + + return results + + + def get_search_results(self, query): + logger.debug(f"Search Query: {query}") + + results = [] + for entry in self.desktop_enteries: + title = entry.getName() + comment = entry.getComment() + if query in title.lower() or query in comment.lower(): + try_exec = entry.getTryExec() + main_exec = entry.getExec() + results.append( [f"{title} || {comment}", f" {try_exec} || {main_exec}"] ) + + return results + + def get_sub_group(self, group): + results = [] + + for entry in self.groups[group]: + results.append( [f"{entry.getName()} || {entry.getComment()}", f" {entry.getTryExec()} || {entry.getExec()}"] ) + + return results diff --git a/src/core/widgets/menu.py b/src/core/widgets/menu.py new file mode 100644 index 0000000..32acc20 --- /dev/null +++ b/src/core/widgets/menu.py @@ -0,0 +1,134 @@ +# Python imports +import traceback + +# from pprint import pprint + +# Lib imports +from libs.PyInquirer import style_from_dict, Token, prompt, Separator + +# Application imports + + + +class Menu: + """ + The menu class has sub methods that are called per run. + """ + + def __init__(self, args, unknownargs): + self.theme = settings_manager.call_method(settings_manager.get_styles(), args.theme) + base_options = ["[ TO MAIN MENU ]", "Favorites"] + body_menu = [ x.title() for x in settings.filters.__slots__ ] + GROUPS = [ "Search...", "Favorites" ] + body_menu + [ "[ Set Favorites ]", "[ Exit ]" ] + query = "" + group = "" + + self._setup_styling() + self._setup_signals() + self._subscribe_to_events() + self._load_widgets() + + while True: + try: + event_system.emit("clear_console") + results = None + group = self.main_menu(GROUPS)["group"] + event_system.emit("clear_console") + + match group: + case "Search...": + query = self.search_menu()["query"] + results = event_system.emit_and_await("get_search_results", (query.lower(),)) + case "Favorites": + results = event_system.emit_and_await("get_favorites_results", (group,)) + case "[ Set Favorites ]": + results = event_system.emit_and_await("get_search_results", ("",)) + programs_list = [{"name" : "[ TO MAIN MENU ]"}] + [ + {"name": prog, "checked": prog in settings.favorites["apps"]} for prog, exec in results + ] + favorites = self.set_favorites_menu(programs_list)["set_faves"] + settings.favorites["apps"] = favorites + continue + case "[ Exit ]": + break + case _: + results = event_system.emit_and_await("get_sub_group", (group,)) + + programs_list = ["[ TO MAIN MENU ]"] + [prog for prog, exec in results] + entry = self.sub_menu([group, programs_list])["prog"] + if entry not in base_options: + for prog, exec_ops in results: + if prog == entry: + event_system.emit("execute_program", (exec_ops,)) + break + except Exception as e: + logger.debug(f"Traceback: {traceback.print_exc()}") + logger.debug(f"Exception: {e}") + + settings_manager.save_settings() + + + def _setup_styling(self): + ... + + def _setup_signals(self): + ... + + def _subscribe_to_events(self): + ... + + def _load_widgets(self): + ... + + + def main_menu(self, _group_list = None): + """ + Displays the main menu using the defined GROUPS list... + """ + group_list = GROUPS if not _group_list else _group_list + menu = { + 'type': 'list', + 'name': 'group', + 'message': '[ MAIN MENU ]', + 'choices': group_list + } + + return prompt(menu, style=self.theme) + + + def set_favorites_menu(self, _group_list = None): + GROUPS = [{'name': '[ TO MAIN MENU ]'}, {'name': 'This is a stub method for Favorites...'}] + group_list = GROUPS if not _group_list else _group_list + menu = { + 'type': 'checkbox', + 'qmark': '>', + 'message': 'Select Favorites', + 'name': 'set_faves', + 'choices': group_list + } + + return prompt(menu, style=self.theme) + + + def sub_menu(self, data = ["NO GROUP NAME", "NO PROGRAMS PASSED IN"]): + group = data[0] + prog_list = data[1] + + menu = { + 'type': 'list', + 'name': 'prog', + 'message': f'[ {group} ]', + 'choices': prog_list + } + + return prompt(menu, style=self.theme) + + + def search_menu(self): + menu = { + 'type': 'input', + 'name': 'query', + 'message': 'Program you\'re looking for: ', + } + + return prompt(menu, style=self.theme) diff --git a/src/core/window.py b/src/core/window.py new file mode 100644 index 0000000..64dc291 --- /dev/null +++ b/src/core/window.py @@ -0,0 +1,51 @@ +# Python imports + +# Lib imports + +# Application imports +from core.controller import Controller + + + +class ControllerStartExceptiom(Exception): + ... + + + +class ApplicationWindow: + """docstring for ApplicationWindow.""" + + def __init__(self): + ... + +class Window(ApplicationWindow): + """ docstring for Window. """ + + def __init__(self, args, unknownargs): + super(Window, self).__init__() + settings_manager.set_main_window(self) + + self._controller = None + + self._setup_styling() + self._setup_signals() + self._subscribe_to_events() + self._load_widgets(args, unknownargs) + + + def _setup_styling(self): + ... + + def _setup_signals(self): + ... + + def _subscribe_to_events(self): + event_system.subscribe("tear_down", self._tear_down) + + def _load_widgets(self, args, unknownargs): + self._controller = Controller(args, unknownargs) + if not self._controller: + raise ControllerStartException("Controller exited and doesn't exist...") + + def _tear_down(self, widget = None, eve = None): + settings_manager.save_settings() diff --git a/src/libs/PyInquirer/__init__.py b/src/libs/PyInquirer/__init__.py new file mode 100644 index 0000000..5cd0619 --- /dev/null +++ b/src/libs/PyInquirer/__init__.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, print_function +import os + +from libs.prompt_toolkit.token import Token +from libs.prompt_toolkit.styles import style_from_dict +from libs.prompt_toolkit.validation import Validator, ValidationError + +from .utils import print_json, format_json + + +__version__ = '1.0.2' + + +def here(p): + return os.path.abspath(os.path.join(os.path.dirname(__file__), p)) + + +class PromptParameterException(ValueError): + def __init__(self, message, errors=None): + + # Call the base class constructor with the parameters it needs + super(PromptParameterException, self).__init__( + 'You must provide a `%s` value' % message, errors) + +from .prompt import prompt +from .separator import Separator +from .prompts.common import default_style diff --git a/src/libs/PyInquirer/color_print.py b/src/libs/PyInquirer/color_print.py new file mode 100644 index 0000000..0841804 --- /dev/null +++ b/src/libs/PyInquirer/color_print.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +""" +provide colorized output +""" +from __future__ import print_function, unicode_literals +import sys +from libs.prompt_toolkit.shortcuts import print_tokens, style_from_dict, Token + + +def _print_token_factory(col): + """Internal helper to provide color names.""" + def _helper(msg): + style = style_from_dict({ + Token.Color: col, + }) + tokens = [ + (Token.Color, msg) + ] + print_tokens(tokens, style=style) + + def _helper_no_terminal(msg): + # workaround if we have no terminal + print(msg) + if sys.stdout.isatty(): + return _helper + else: + return _helper_no_terminal + +# used this for color source: +# http://unix.stackexchange.com/questions/105568/how-can-i-list-the-available-color-names +yellow = _print_token_factory('#dfaf00') +blue = _print_token_factory('#0087ff') +gray = _print_token_factory('#6c6c6c') + +# TODO +#black +#red +#green +#magenta +#cyan +#white diff --git a/src/libs/PyInquirer/prompt.py b/src/libs/PyInquirer/prompt.py new file mode 100644 index 0000000..bbeab54 --- /dev/null +++ b/src/libs/PyInquirer/prompt.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import, print_function + +from libs.prompt_toolkit.shortcuts import run_application + +from . import PromptParameterException, prompts +from .prompts import list, confirm, input, password, checkbox, rawlist, expand, editor + + +def prompt(questions, answers=None, **kwargs): + if isinstance(questions, dict): + questions = [questions] + answers = answers or {} + + patch_stdout = kwargs.pop('patch_stdout', False) + return_asyncio_coroutine = kwargs.pop('return_asyncio_coroutine', False) + true_color = kwargs.pop('true_color', False) + refresh_interval = kwargs.pop('refresh_interval', 0) + eventloop = kwargs.pop('eventloop', None) + kbi_msg = kwargs.pop('keyboard_interrupt_msg', 'Cancelled by user') + + for question in questions: + # import the question + if 'type' not in question: + raise PromptParameterException('type') + if 'name' not in question: + raise PromptParameterException('name') + if 'message' not in question: + raise PromptParameterException('message') + try: + choices = question.get('choices') + if choices is not None and callable(choices): + question['choices'] = choices(answers) + + _kwargs = {} + _kwargs.update(kwargs) + _kwargs.update(question) + type = _kwargs.pop('type') + name = _kwargs.pop('name') + message = _kwargs.pop('message') + when = _kwargs.pop('when', None) + filter = _kwargs.pop('filter', None) + + if when: + # at least a little sanity check! + if callable(question['when']): + try: + if not question['when'](answers): + continue + except Exception as e: + raise ValueError( + 'Problem in \'when\' check of %s question: %s' % + (name, e)) + else: + raise ValueError('\'when\' needs to be function that ' \ + 'accepts a dict argument') + if filter: + # at least a little sanity check! + if not callable(question['filter']): + raise ValueError('\'filter\' needs to be function that ' \ + 'accepts an argument') + + if callable(question.get('default')): + _kwargs['default'] = question['default'](answers) + + application = getattr(prompts, type).question(message, **_kwargs) + + answer = run_application( + application, + patch_stdout=patch_stdout, + return_asyncio_coroutine=return_asyncio_coroutine, + true_color=true_color, + refresh_interval=refresh_interval, + eventloop=eventloop) + + if answer is not None: + if filter: + try: + answer = question['filter'](answer) + except Exception as e: + raise ValueError( + 'Problem processing \'filter\' of %s question: %s' % + (name, e)) + answers[name] = answer + except AttributeError as e: + print(e) + raise ValueError('No question type \'%s\'' % type) + except KeyboardInterrupt: + print('') + print(kbi_msg) + print('') + return {} + return answers + + +# TODO: +# Bottom Bar - inquirer.ui.BottomBar diff --git a/src/libs/PyInquirer/prompts/__init__.py b/src/libs/PyInquirer/prompts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/libs/PyInquirer/prompts/checkbox.py b/src/libs/PyInquirer/prompts/checkbox.py new file mode 100644 index 0000000..33ffdeb --- /dev/null +++ b/src/libs/PyInquirer/prompts/checkbox.py @@ -0,0 +1,233 @@ +# -*- coding: utf-8 -*- +""" +`checkbox` type question +""" +from __future__ import print_function, unicode_literals +from libs.prompt_toolkit.application import Application +from libs.prompt_toolkit.key_binding.manager import KeyBindingManager +from libs.prompt_toolkit.keys import Keys +from libs.prompt_toolkit.layout.containers import Window +from libs.prompt_toolkit.filters import IsDone +from libs.prompt_toolkit.layout.controls import TokenListControl +from libs.prompt_toolkit.layout.containers import ConditionalContainer, \ + ScrollOffsets, HSplit +from libs.prompt_toolkit.layout.dimension import LayoutDimension as D +from libs.prompt_toolkit.token import Token + +from .. import PromptParameterException +from ..separator import Separator +from .common import setup_simple_validator, default_style, if_mousedown + + +# custom control based on TokenListControl + + +class InquirerControl(TokenListControl): + def __init__(self, choices, **kwargs): + self.pointer_index = 0 + self.selected_options = [] # list of names + self.answered = False + self._init_choices(choices) + super(InquirerControl, self).__init__(self._get_choice_tokens, + **kwargs) + + def _init_choices(self, choices): + # helper to convert from question format to internal format + self.choices = [] # list (name, value) + searching_first_choice = True + for i, c in enumerate(choices): + if isinstance(c, Separator): + self.choices.append(c) + else: + name = c['name'] + value = c.get('value', name) + disabled = c.get('disabled', None) + if 'checked' in c and c['checked'] and not disabled: + self.selected_options.append(c['name']) + self.choices.append((name, value, disabled)) + if searching_first_choice and not disabled: # find the first (available) choice + self.pointer_index = i + searching_first_choice = False + + @property + def choice_count(self): + return len(self.choices) + + def _get_choice_tokens(self, cli): + tokens = [] + T = Token + + def append(index, line): + if isinstance(line, Separator): + tokens.append((T.Separator, ' %s\n' % line)) + else: + line_name = line[0] + line_value = line[1] + selected = (line_value in self.selected_options) # use value to check if option has been selected + pointed_at = (index == self.pointer_index) + + @if_mousedown + def select_item(cli, mouse_event): + # bind option with this index to mouse event + if line_value in self.selected_options: + self.selected_options.remove(line_value) + else: + self.selected_options.append(line_value) + + if pointed_at: + tokens.append((T.Pointer, ' \u276f', select_item)) # ' >' + else: + tokens.append((T, ' ', select_item)) + # 'o ' - FISHEYE + if choice[2]: # disabled + tokens.append((T, '- %s (%s)' % (choice[0], choice[2]))) + else: + if selected: + tokens.append((T.Selected, '\u25cf ', select_item)) + else: + tokens.append((T, '\u25cb ', select_item)) + + if pointed_at: + tokens.append((Token.SetCursorPosition, '')) + + tokens.append((T, line_name, select_item)) + tokens.append((T, '\n')) + + # prepare the select choices + for i, choice in enumerate(self.choices): + append(i, choice) + tokens.pop() # Remove last newline. + return tokens + + def get_selected_values(self): + # get values not labels + return [c[1] for c in self.choices if not isinstance(c, Separator) and + c[1] in self.selected_options] + + @property + def line_count(self): + return len(self.choices) + + +def question(message, **kwargs): + # TODO add bottom-bar (Move up and down to reveal more choices) + # TODO extract common parts for list, checkbox, rawlist, expand + # TODO validate + if not 'choices' in kwargs: + raise PromptParameterException('choices') + # this does not implement default, use checked... + if 'default' in kwargs: + raise ValueError('Checkbox does not implement \'default\' ' + 'use \'checked\':True\' in choice!') + + choices = kwargs.pop('choices', None) + validator = setup_simple_validator(kwargs) + + # TODO style defaults on detail level + style = kwargs.pop('style', default_style) + + ic = InquirerControl(choices) + qmark = kwargs.pop('qmark', '?') + + def get_prompt_tokens(cli): + tokens = [] + + tokens.append((Token.QuestionMark, qmark)) + tokens.append((Token.Question, ' %s ' % message)) + if ic.answered: + nbr_selected = len(ic.selected_options) + if nbr_selected == 0: + tokens.append((Token.Answer, ' done')) + elif nbr_selected == 1: + tokens.append((Token.Answer, ' [%s]' % ic.selected_options[0])) + else: + tokens.append((Token.Answer, + ' done (%d selections)' % nbr_selected)) + else: + tokens.append((Token.Instruction, + ' (, to move, to select, ' + 'to toggle, to invert)')) + return tokens + + # assemble layout + layout = HSplit([ + Window(height=D.exact(1), + content=TokenListControl(get_prompt_tokens, align_center=False) + ), + ConditionalContainer( + Window( + ic, + width=D.exact(43), + height=D(min=3), + scroll_offsets=ScrollOffsets(top=1, bottom=1) + ), + filter=~IsDone() + ) + ]) + + # key bindings + manager = KeyBindingManager.for_prompt() + + @manager.registry.add_binding(Keys.ControlQ, eager=True) + @manager.registry.add_binding(Keys.ControlC, eager=True) + def _(event): + raise KeyboardInterrupt() + # event.cli.set_return_value(None) + + @manager.registry.add_binding(' ', eager=True) + def toggle(event): + pointed_choice = ic.choices[ic.pointer_index][1] # value + if pointed_choice in ic.selected_options: + ic.selected_options.remove(pointed_choice) + else: + ic.selected_options.append(pointed_choice) + + @manager.registry.add_binding('i', eager=True) + def invert(event): + inverted_selection = [c[1] for c in ic.choices if + not isinstance(c, Separator) and + c[1] not in ic.selected_options and + not c[2]] + ic.selected_options = inverted_selection + + @manager.registry.add_binding('a', eager=True) + def all(event): + all_selected = True # all choices have been selected + for c in ic.choices: + if not isinstance(c, Separator) and c[1] not in ic.selected_options and not c[2]: + # add missing ones + ic.selected_options.append(c[1]) + all_selected = False + if all_selected: + ic.selected_options = [] + + @manager.registry.add_binding(Keys.Down, eager=True) + def move_cursor_down(event): + def _next(): + ic.pointer_index = ((ic.pointer_index + 1) % ic.line_count) + _next() + while isinstance(ic.choices[ic.pointer_index], Separator) or \ + ic.choices[ic.pointer_index][2]: + _next() + + @manager.registry.add_binding(Keys.Up, eager=True) + def move_cursor_up(event): + def _prev(): + ic.pointer_index = ((ic.pointer_index - 1) % ic.line_count) + _prev() + while isinstance(ic.choices[ic.pointer_index], Separator) or \ + ic.choices[ic.pointer_index][2]: + _prev() + + @manager.registry.add_binding(Keys.Enter, eager=True) + def set_answer(event): + ic.answered = True + # TODO use validator + event.cli.set_return_value(ic.get_selected_values()) + + return Application( + layout=layout, + key_bindings_registry=manager.registry, + mouse_support=True, + style=style + ) diff --git a/src/libs/PyInquirer/prompts/common.py b/src/libs/PyInquirer/prompts/common.py new file mode 100644 index 0000000..cbecae4 --- /dev/null +++ b/src/libs/PyInquirer/prompts/common.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +""" +common prompt functionality +""" + +import sys + +from libs.prompt_toolkit.validation import Validator, ValidationError +from libs.prompt_toolkit.styles import style_from_dict +from libs.prompt_toolkit.token import Token +from libs.prompt_toolkit.mouse_events import MouseEventTypes + + +PY3 = sys.version_info[0] >= 3 + +if PY3: + basestring = str + + +def if_mousedown(handler): + def handle_if_mouse_down(cli, mouse_event): + if mouse_event.event_type == MouseEventTypes.MOUSE_DOWN: + return handler(cli, mouse_event) + else: + return NotImplemented + + return handle_if_mouse_down + + +# TODO probably better to use base.Condition +def setup_validator(kwargs): + # this is an internal helper not meant for public consumption! + # note this works on a dictionary + validate_prompt = kwargs.pop('validate', None) + if validate_prompt: + if issubclass(validate_prompt, Validator): + kwargs['validator'] = validate_prompt() + elif callable(validate_prompt): + class _InputValidator(Validator): + def validate(self, document): + #print('validation!!') + verdict = validate_prompt(document.text) + if isinstance(verdict, basestring): + raise ValidationError( + message=verdict, + cursor_position=len(document.text)) + elif verdict is not True: + raise ValidationError( + message='invalid input', + cursor_position=len(document.text)) + kwargs['validator'] = _InputValidator() + return kwargs['validator'] + + +def setup_simple_validator(kwargs): + # this is an internal helper not meant for public consumption! + # note this works on a dictionary + # this validates the answer not a buffer + # TODO + # not sure yet how to deal with the validation result: + # https://github.com/jonathanslenders/python-prompt-toolkit/issues/430 + validate = kwargs.pop('validate', None) + if validate is None: + def _always(answer): + return True + return _always + elif not callable(validate): + raise ValueError('Here a simple validate function is expected, no class') + + def _validator(answer): + verdict = validate(answer) + if isinstance(verdict, basestring): + raise ValidationError( + message=verdict + ) + elif verdict is not True: + raise ValidationError( + message='invalid input' + ) + return _validator + + +# FIXME style defaults on detail level +default_style = style_from_dict({ + Token.Separator: '#6C6C6C', + Token.QuestionMark: '#5F819D', + Token.Selected: '', # default + Token.Pointer: '#FF9D00 bold', # AWS orange + Token.Instruction: '', # default + Token.Answer: '#FF9D00 bold', # AWS orange + Token.Question: 'bold', +}) diff --git a/src/libs/PyInquirer/prompts/confirm.py b/src/libs/PyInquirer/prompts/confirm.py new file mode 100644 index 0000000..3fbc607 --- /dev/null +++ b/src/libs/PyInquirer/prompts/confirm.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +""" +confirm type question +""" +from __future__ import print_function, unicode_literals +from libs.prompt_toolkit.application import Application +from libs.prompt_toolkit.key_binding.manager import KeyBindingManager +from libs.prompt_toolkit.keys import Keys +from libs.prompt_toolkit.layout.containers import Window, HSplit +from libs.prompt_toolkit.layout.controls import TokenListControl +from libs.prompt_toolkit.layout.dimension import LayoutDimension as D +from libs.prompt_toolkit.token import Token +from libs.prompt_toolkit.shortcuts import create_prompt_application +from libs.prompt_toolkit.styles import style_from_dict + + +# custom control based on TokenListControl + + +def question(message, **kwargs): + # TODO need ENTER confirmation + default = kwargs.pop('default', True) + + # TODO style defaults on detail level + style = kwargs.pop('style', style_from_dict({ + Token.QuestionMark: '#5F819D', + #Token.Selected: '#FF9D00', # AWS orange + Token.Instruction: '', # default + Token.Answer: '#FF9D00 bold', # AWS orange + Token.Question: 'bold', + })) + status = {'answer': None} + + qmark = kwargs.pop('qmark', '?') + + def get_prompt_tokens(cli): + tokens = [] + + tokens.append((Token.QuestionMark, qmark)) + tokens.append((Token.Question, ' %s ' % message)) + if isinstance(status['answer'], bool): + tokens.append((Token.Answer, ' Yes' if status['answer'] else ' No')) + else: + if default: + instruction = ' (Y/n)' + else: + instruction = ' (y/N)' + tokens.append((Token.Instruction, instruction)) + return tokens + + # key bindings + manager = KeyBindingManager.for_prompt() + + @manager.registry.add_binding(Keys.ControlQ, eager=True) + @manager.registry.add_binding(Keys.ControlC, eager=True) + def _(event): + raise KeyboardInterrupt() + + @manager.registry.add_binding('n') + @manager.registry.add_binding('N') + def key_n(event): + status['answer'] = False + event.cli.set_return_value(False) + + @manager.registry.add_binding('y') + @manager.registry.add_binding('Y') + def key_y(event): + status['answer'] = True + event.cli.set_return_value(True) + + @manager.registry.add_binding(Keys.Enter, eager=True) + def set_answer(event): + status['answer'] = default + event.cli.set_return_value(default) + + return create_prompt_application( + get_prompt_tokens=get_prompt_tokens, + key_bindings_registry=manager.registry, + mouse_support=False, + style=style, + erase_when_done=False, + ) diff --git a/src/libs/PyInquirer/prompts/editor.py b/src/libs/PyInquirer/prompts/editor.py new file mode 100644 index 0000000..0d4b8d6 --- /dev/null +++ b/src/libs/PyInquirer/prompts/editor.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- +""" +`editor` type question +""" +from __future__ import print_function, unicode_literals +import os +import sys +from libs.prompt_toolkit.token import Token +from libs.prompt_toolkit.shortcuts import create_prompt_application +from libs.prompt_toolkit.validation import Validator, ValidationError +from libs.prompt_toolkit.layout.lexers import SimpleLexer + +from .common import default_style + + +# use std prompt-toolkit control + +WIN = sys.platform.startswith('win') + +class EditorArgumentsError(Exception): + pass + +class Editor(object): + + def __init__(self, editor=None, env=None, require_save=True, extension='.txt'): + self.editor = editor + self.env = env + self.require_save = require_save + self.extension = extension + + def get_editor(self): + if self.editor is not None and self.editor.lower() != "default": + return self.editor + for key in 'VISUAL', 'EDITOR': + rv = os.environ.get(key) + if rv: + return rv + if WIN: + return 'notepad' + for editor in 'vim', 'nano': + if os.system('which %s >/dev/null 2>&1' % editor) == 0: + return editor + return 'vi' + + def edit_file(self, filename): + import subprocess + editor = self.get_editor() + if self.env: + environ = os.environ.copy() + environ.update(self.env) + else: + environ = None + try: + c = subprocess.Popen('%s "%s"' % (editor, filename), + env=environ, shell=True) + exit_code = c.wait() + if exit_code != 0: + raise Exception('%s: Editing failed!' % editor) + except OSError as e: + raise Exception('%s: Editing failed: %s' % (editor, e)) + + def edit(self, text): + import tempfile + + text = text or '' + if text and not text.endswith('\n'): + text += '\n' + + fd, name = tempfile.mkstemp(prefix='editor-', suffix=self.extension) + try: + if WIN: + encoding = 'utf-8-sig' + text = text.replace('\n', '\r\n') + else: + encoding = 'utf-8' + text = text.encode(encoding) + + f = os.fdopen(fd, 'wb') + f.write(text) + f.close() + timestamp = os.path.getmtime(name) + + self.edit_file(name) + + if self.require_save \ + and os.path.getmtime(name) == timestamp: + return None + + f = open(name, 'rb') + try: + rv = f.read() + finally: + f.close() + return rv.decode('utf-8-sig').replace('\r\n', '\n') + finally: + os.unlink(name) + +def edit(text=None, editor=None, env=None, require_save=True, + extension='.txt', filename=None): + r"""Edits the given text in the defined editor. If an editor is given + (should be the full path to the executable but the regular operating + system search path is used for finding the executable) it overrides + the detected editor. Optionally, some environment variables can be + used. If the editor is closed without changes, `None` is returned. In + case a file is edited directly the return value is always `None` and + `require_save` and `extension` are ignored. + + If the editor cannot be opened a :exc:`UsageError` is raised. + + Note for Windows: to simplify cross-platform usage, the newlines are + automatically converted from POSIX to Windows and vice versa. As such, + the message here will have ``\n`` as newline markers. + + :param text: the text to edit. + :param editor: optionally the editor to use. Defaults to automatic + detection. + :param env: environment variables to forward to the editor. + :param require_save: if this is true, then not saving in the editor + will make the return value become `None`. + :param extension: the extension to tell the editor about. This defaults + to `.txt` but changing this might change syntax + highlighting. + :param filename: if provided it will edit this file instead of the + provided text contents. It will not use a temporary + file as an indirection in that case. + """ + + editor = Editor(editor=editor, env=env, require_save=require_save, + extension=extension) + if filename is None: + return editor.edit(text) + editor.edit_file(filename) + +def question(message, **kwargs): + default = kwargs.pop('default', '') + eargs = kwargs.pop('eargs', {}) + validate_prompt = kwargs.pop('validate', None) + if validate_prompt: + if issubclass(validate_prompt, Validator): + kwargs['validator'] = validate_prompt() + elif callable(validate_prompt): + class _InputValidator(Validator): + def validate(self, document): + verdict = validate_prompt(document.text) + if not verdict == True: + if verdict == False: + verdict = 'invalid input' + raise ValidationError( + message=verdict, + cursor_position=len(document.text)) + kwargs['validator'] = _InputValidator() + + for k, v in eargs.items(): + if v == "" or v == " ": + raise EditorArgumentsError( + "Args '{}' value should not be empty".format(k) + ) + + editor = eargs.get("editor", None) + ext = eargs.get("ext", ".txt") + env = eargs.get("env", None) + text = default + filename = eargs.get("filename", None) + multiline = True if not editor else False + save = eargs.get("save", None) + + if editor: + _text = edit( + editor=editor, + extension=ext, + text=text, + env=env, + filename=filename, + require_save=save + ) + if filename: + default = filename + else: + default = _text + + # TODO style defaults on detail level + kwargs['style'] = kwargs.pop('style', default_style) + qmark = kwargs.pop('qmark', '?') + + def _get_prompt_tokens(cli): + return [ + (Token.QuestionMark, qmark), + (Token.Question, ' %s ' % message) + ] + + return create_prompt_application( + get_prompt_tokens=_get_prompt_tokens, + lexer=SimpleLexer(Token.Answer), + default=default, + multiline=multiline, + **kwargs + ) diff --git a/src/libs/PyInquirer/prompts/expand.py b/src/libs/PyInquirer/prompts/expand.py new file mode 100644 index 0000000..f3c0c38 --- /dev/null +++ b/src/libs/PyInquirer/prompts/expand.py @@ -0,0 +1,195 @@ +# -*- coding: utf-8 -*- +""" +`expand` type question +""" +from __future__ import print_function, unicode_literals + +import sys + +from libs.prompt_toolkit.application import Application +from libs.prompt_toolkit.key_binding.manager import KeyBindingManager +from libs.prompt_toolkit.keys import Keys +from libs.prompt_toolkit.layout.containers import Window +from libs.prompt_toolkit.filters import IsDone +from libs.prompt_toolkit.layout.controls import TokenListControl +from libs.prompt_toolkit.layout.containers import ConditionalContainer, HSplit +from libs.prompt_toolkit.layout.dimension import LayoutDimension as D +from libs.prompt_toolkit.token import Token + +from .. import PromptParameterException +from ..separator import Separator +from .common import default_style +from .common import if_mousedown + + +PY3 = sys.version_info[0] >= 3 + +if PY3: + basestring = str + + +# custom control based on TokenListControl + +class InquirerControl(TokenListControl): + def __init__(self, choices, default=None, **kwargs): + self.pointer_index = 0 + self.answered = False + self._init_choices(choices, default) + self._help_active = False # help is activated via 'h' key + super(InquirerControl, self).__init__(self._get_choice_tokens, + **kwargs) + + def _init_choices(self, choices, default=None): + # helper to convert from question format to internal format + + self.choices = [] # list (key, name, value) + + if not default: + default = 'h' + for i, c in enumerate(choices): + if isinstance(c, Separator): + self.choices.append(c) + else: + if isinstance(c, basestring): + self.choices.append((key, c, c)) + else: + key = c.get('key') + name = c.get('name') + value = c.get('value', name) + if default and default == key: + self.pointer_index = i + key = key.upper() # default key is in uppercase + self.choices.append((key, name, value)) + # append the help choice + key = 'h' + if not default: + self.pointer_index = len(self.choices) + key = key.upper() # default key is in uppercase + self.choices.append((key, 'Help, list all options', None)) + + @property + def choice_count(self): + return len(self.choices) + + def _get_choice_tokens(self, cli): + tokens = [] + T = Token + + def _append(index, line): + if isinstance(line, Separator): + tokens.append((T.Separator, ' %s\n' % line)) + else: + key = line[0] + line = line[1] + pointed_at = (index == self.pointer_index) + + @if_mousedown + def select_item(cli, mouse_event): + # bind option with this index to mouse event + self.pointer_index = index + + if pointed_at: + tokens.append((T.Selected, ' %s) %s' % (key, line), + select_item)) + else: + tokens.append((T, ' %s) %s' % (key, line), + select_item)) + tokens.append((T, '\n')) + + if self._help_active: + # prepare the select choices + for i, choice in enumerate(self.choices): + _append(i, choice) + tokens.append((T, ' Answer: %s' % + self.choices[self.pointer_index][0])) + else: + tokens.append((T.Pointer, '>> ')) + tokens.append((T, self.choices[self.pointer_index][1])) + return tokens + + def get_selected_value(self): + # get value not label + return self.choices[self.pointer_index][2] + + +def question(message, **kwargs): + # TODO extract common parts for list, checkbox, rawlist, expand + # TODO up, down navigation + if not 'choices' in kwargs: + raise PromptParameterException('choices') + + choices = kwargs.pop('choices', None) + default = kwargs.pop('default', None) + qmark = kwargs.pop('qmark', '?') + # TODO style defaults on detail level + style = kwargs.pop('style', default_style) + + ic = InquirerControl(choices, default) + + def get_prompt_tokens(cli): + tokens = [] + T = Token + + tokens.append((T.QuestionMark, qmark)) + tokens.append((T.Question, ' %s ' % message)) + if not ic.answered: + tokens.append((T.Instruction, ' (%s)' % ''.join( + [k[0] for k in ic.choices if not isinstance(k, Separator)]))) + else: + tokens.append((T.Answer, ' %s' % ic.get_selected_value())) + return tokens + + #@Condition + #def is_help_active(cli): + # return ic._help_active + + # assemble layout + layout = HSplit([ + Window(height=D.exact(1), + content=TokenListControl(get_prompt_tokens) + ), + ConditionalContainer( + Window(ic), + #filter=is_help_active & ~IsDone() # ~ bitwise inverse + filter=~IsDone() # ~ bitwise inverse + ) + ]) + + # key bindings + manager = KeyBindingManager.for_prompt() + + @manager.registry.add_binding(Keys.ControlQ, eager=True) + @manager.registry.add_binding(Keys.ControlC, eager=True) + def _(event): + raise KeyboardInterrupt() + + # add key bindings for choices + for i, c in enumerate(ic.choices): + if not isinstance(c, Separator): + def _reg_binding(i, keys): + # trick out late evaluation with a "function factory": + # http://stackoverflow.com/questions/3431676/creating-functions-in-a-loop + @manager.registry.add_binding(keys, eager=True) + def select_choice(event): + ic.pointer_index = i + if c[0] not in ['h', 'H']: + _reg_binding(i, c[0]) + if c[0].isupper(): + _reg_binding(i, c[0].lower()) + + @manager.registry.add_binding('H', eager=True) + @manager.registry.add_binding('h', eager=True) + def help_choice(event): + ic._help_active = True + + @manager.registry.add_binding(Keys.Enter, eager=True) + def set_answer(event): + ic.answered = True + event.cli.set_return_value(ic.get_selected_value()) + + return Application( + layout=layout, + key_bindings_registry=manager.registry, + mouse_support=True, + style=style + ) diff --git a/src/libs/PyInquirer/prompts/input.py b/src/libs/PyInquirer/prompts/input.py new file mode 100644 index 0000000..fb134fa --- /dev/null +++ b/src/libs/PyInquirer/prompts/input.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +""" +`input` type question +""" +from __future__ import print_function, unicode_literals +import inspect +from libs.prompt_toolkit.token import Token +from libs.prompt_toolkit.shortcuts import create_prompt_application +from libs.prompt_toolkit.validation import Validator, ValidationError +from libs.prompt_toolkit.layout.lexers import SimpleLexer + +from .common import default_style + +# use std prompt-toolkit control + + +def question(message, **kwargs): + default = kwargs.pop('default', '') + validate_prompt = kwargs.pop('validate', None) + if validate_prompt: + if inspect.isclass(validate_prompt) and issubclass(validate_prompt, Validator): + kwargs['validator'] = validate_prompt() + elif callable(validate_prompt): + class _InputValidator(Validator): + def validate(self, document): + verdict = validate_prompt(document.text) + if not verdict == True: + if verdict == False: + verdict = 'invalid input' + raise ValidationError( + message=verdict, + cursor_position=len(document.text)) + kwargs['validator'] = _InputValidator() + + # TODO style defaults on detail level + kwargs['style'] = kwargs.pop('style', default_style) + qmark = kwargs.pop('qmark', '?') + + + def _get_prompt_tokens(cli): + return [ + (Token.QuestionMark, qmark), + (Token.Question, ' %s ' % message) + ] + + return create_prompt_application( + get_prompt_tokens=_get_prompt_tokens, + lexer=SimpleLexer(Token.Answer), + default=default, + **kwargs + ) diff --git a/src/libs/PyInquirer/prompts/list.py b/src/libs/PyInquirer/prompts/list.py new file mode 100644 index 0000000..9b18a94 --- /dev/null +++ b/src/libs/PyInquirer/prompts/list.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- +""" +`list` type question +""" +from __future__ import print_function +from __future__ import unicode_literals + +import sys + +from libs.prompt_toolkit.application import Application +from libs.prompt_toolkit.key_binding.manager import KeyBindingManager +from libs.prompt_toolkit.keys import Keys +from libs.prompt_toolkit.layout.containers import Window +from libs.prompt_toolkit.filters import IsDone +from libs.prompt_toolkit.layout.controls import TokenListControl +from libs.prompt_toolkit.layout.containers import ConditionalContainer, \ + ScrollOffsets, HSplit +from libs.prompt_toolkit.layout.dimension import LayoutDimension as D +from libs.prompt_toolkit.token import Token + +from .. import PromptParameterException +from ..separator import Separator +from .common import if_mousedown, default_style + +# custom control based on TokenListControl +# docu here: +# https://github.com/jonathanslenders/python-prompt-toolkit/issues/281 +# https://github.com/jonathanslenders/python-prompt-toolkit/blob/master/examples/full-screen-layout.py +# https://github.com/jonathanslenders/python-prompt-toolkit/blob/master/docs/pages/full_screen_apps.rst + + +PY3 = sys.version_info[0] >= 3 + +if PY3: + basestring = str + + +class InquirerControl(TokenListControl): + def __init__(self, choices, **kwargs): + self.selected_option_index = 0 + self.answered = False + self.choices = choices + self._init_choices(choices) + super(InquirerControl, self).__init__(self._get_choice_tokens, + **kwargs) + + def _init_choices(self, choices, default=None): + # helper to convert from question format to internal format + self.choices = [] # list (name, value, disabled) + searching_first_choice = True + for i, c in enumerate(choices): + if isinstance(c, Separator): + self.choices.append((c, None, None)) + else: + if isinstance(c, basestring): + self.choices.append((c, c, None)) + else: + name = c.get('name') + value = c.get('value', name) + disabled = c.get('disabled', None) + self.choices.append((name, value, disabled)) + if searching_first_choice: + self.selected_option_index = i # found the first choice + searching_first_choice = False + + @property + def choice_count(self): + return len(self.choices) + + def _get_choice_tokens(self, cli): + tokens = [] + T = Token + + def append(index, choice): + selected = (index == self.selected_option_index) + + @if_mousedown + def select_item(cli, mouse_event): + # bind option with this index to mouse event + self.selected_option_index = index + self.answered = True + cli.set_return_value(None) + + tokens.append((T.Pointer if selected else T, ' \u276f ' if selected + else ' ')) + if selected: + tokens.append((Token.SetCursorPosition, '')) + if choice[2]: # disabled + tokens.append((T.Selected if selected else T, + '- %s (%s)' % (choice[0], choice[2]))) + else: + try: + tokens.append((T.Selected if selected else T, str(choice[0]), + select_item)) + except: + tokens.append((T.Selected if selected else T, choice[0], + select_item)) + tokens.append((T, '\n')) + + # prepare the select choices + for i, choice in enumerate(self.choices): + append(i, choice) + tokens.pop() # Remove last newline. + return tokens + + def get_selection(self): + return self.choices[self.selected_option_index] + + +def question(message, **kwargs): + # TODO disabled, dict choices + if not 'choices' in kwargs: + raise PromptParameterException('choices') + + choices = kwargs.pop('choices', None) + default = kwargs.pop('default', 0) # TODO + qmark = kwargs.pop('qmark', '?') + # TODO style defaults on detail level + style = kwargs.pop('style', default_style) + + ic = InquirerControl(choices) + + def get_prompt_tokens(cli): + tokens = [] + + tokens.append((Token.QuestionMark, qmark)) + tokens.append((Token.Question, ' %s ' % message)) + if ic.answered: + tokens.append((Token.Answer, ' ' + ic.get_selection()[0])) + else: + tokens.append((Token.Instruction, ' (Use arrow keys)')) + return tokens + + # assemble layout + layout = HSplit([ + Window(height=D.exact(1), + content=TokenListControl(get_prompt_tokens) + ), + ConditionalContainer( + Window(ic), + filter=~IsDone() + ) + ]) + + # key bindings + manager = KeyBindingManager.for_prompt() + + @manager.registry.add_binding(Keys.ControlQ, eager=True) + @manager.registry.add_binding(Keys.ControlC, eager=True) + def _(event): + raise KeyboardInterrupt() + # event.cli.set_return_value(None) + + @manager.registry.add_binding(Keys.Down, eager=True) + def move_cursor_down(event): + def _next(): + ic.selected_option_index = ( + (ic.selected_option_index + 1) % ic.choice_count) + _next() + while isinstance(ic.choices[ic.selected_option_index][0], Separator) or\ + ic.choices[ic.selected_option_index][2]: + _next() + + @manager.registry.add_binding(Keys.Up, eager=True) + def move_cursor_up(event): + def _prev(): + ic.selected_option_index = ( + (ic.selected_option_index - 1) % ic.choice_count) + _prev() + while isinstance(ic.choices[ic.selected_option_index][0], Separator) or \ + ic.choices[ic.selected_option_index][2]: + _prev() + + @manager.registry.add_binding(Keys.Enter, eager=True) + def set_answer(event): + ic.answered = True + event.cli.set_return_value(ic.get_selection()[1]) + + return Application( + layout=layout, + key_bindings_registry=manager.registry, + mouse_support=True, + style=style + ) diff --git a/src/libs/PyInquirer/prompts/password.py b/src/libs/PyInquirer/prompts/password.py new file mode 100644 index 0000000..1bf294f --- /dev/null +++ b/src/libs/PyInquirer/prompts/password.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +""" +`password` type question +""" +from __future__ import print_function, unicode_literals + +from . import input + +# use std prompt-toolkit control + + +def question(message, **kwargs): + kwargs['is_password'] = True + return input.question(message, **kwargs) diff --git a/src/libs/PyInquirer/prompts/rawlist.py b/src/libs/PyInquirer/prompts/rawlist.py new file mode 100644 index 0000000..976c2d1 --- /dev/null +++ b/src/libs/PyInquirer/prompts/rawlist.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- +""" +`rawlist` type question +""" +from __future__ import print_function, unicode_literals +import sys + +from libs.prompt_toolkit.application import Application +from libs.prompt_toolkit.key_binding.manager import KeyBindingManager +from libs.prompt_toolkit.keys import Keys +from libs.prompt_toolkit.layout.containers import Window +from libs.prompt_toolkit.filters import IsDone +from libs.prompt_toolkit.layout.controls import TokenListControl +from libs.prompt_toolkit.layout.containers import ConditionalContainer, HSplit +from libs.prompt_toolkit.layout.dimension import LayoutDimension as D +from libs.prompt_toolkit.token import Token + +from .. import PromptParameterException +from ..separator import Separator +from .common import default_style +from .common import if_mousedown + + +PY3 = sys.version_info[0] >= 3 + +if PY3: + basestring = str + + +# custom control based on TokenListControl + +class InquirerControl(TokenListControl): + def __init__(self, choices, **kwargs): + self.pointer_index = 0 + self.answered = False + self._init_choices(choices) + super(InquirerControl, self).__init__(self._get_choice_tokens, + **kwargs) + + def _init_choices(self, choices): + # helper to convert from question format to internal format + self.choices = [] # list (key, name, value) + searching_first_choice = True + key = 1 # used for numeric keys + for i, c in enumerate(choices): + if isinstance(c, Separator): + self.choices.append(c) + else: + if isinstance(c, basestring): + self.choices.append((key, c, c)) + key += 1 + if searching_first_choice: + self.pointer_index = i # found the first choice + searching_first_choice = False + + @property + def choice_count(self): + return len(self.choices) + + def _get_choice_tokens(self, cli): + tokens = [] + T = Token + + def _append(index, line): + if isinstance(line, Separator): + tokens.append((T.Separator, ' %s\n' % line)) + else: + key = line[0] + line = line[1] + pointed_at = (index == self.pointer_index) + + @if_mousedown + def select_item(cli, mouse_event): + # bind option with this index to mouse event + self.pointer_index = index + + if pointed_at: + tokens.append((T.Selected, ' %d) %s' % (key, line), + select_item)) + else: + tokens.append((T, ' %d) %s' % (key, line), + select_item)) + + tokens.append((T, '\n')) + + # prepare the select choices + for i, choice in enumerate(self.choices): + _append(i, choice) + tokens.append((T, ' Answer: %d' % self.choices[self.pointer_index][0])) + return tokens + + def get_selected_value(self): + # get value not label + return self.choices[self.pointer_index][2] + + +def question(message, **kwargs): + # TODO extract common parts for list, checkbox, rawlist, expand + if not 'choices' in kwargs: + raise PromptParameterException('choices') + # this does not implement default, use checked... + # TODO + #if 'default' in kwargs: + # raise ValueError('rawlist does not implement \'default\' ' + # 'use \'checked\':True\' in choice!') + qmark = kwargs.pop('qmark', '?') + choices = kwargs.pop('choices', None) + if len(choices) > 9: + raise ValueError('rawlist supports only a maximum of 9 choices!') + + # TODO style defaults on detail level + style = kwargs.pop('style', default_style) + + ic = InquirerControl(choices) + + def get_prompt_tokens(cli): + tokens = [] + T = Token + + tokens.append((T.QuestionMark, qmark)) + tokens.append((T.Question, ' %s ' % message)) + if ic.answered: + tokens.append((T.Answer, ' %s' % ic.get_selected_value())) + return tokens + + # assemble layout + layout = HSplit([ + Window(height=D.exact(1), + content=TokenListControl(get_prompt_tokens) + ), + ConditionalContainer( + Window(ic), + filter=~IsDone() + ) + ]) + + # key bindings + manager = KeyBindingManager.for_prompt() + + @manager.registry.add_binding(Keys.ControlQ, eager=True) + @manager.registry.add_binding(Keys.ControlC, eager=True) + def _(event): + raise KeyboardInterrupt() + + # add key bindings for choices + for i, c in enumerate(ic.choices): + if not isinstance(c, Separator): + def _reg_binding(i, keys): + # trick out late evaluation with a "function factory": + # http://stackoverflow.com/questions/3431676/creating-functions-in-a-loop + @manager.registry.add_binding(keys, eager=True) + def select_choice(event): + ic.pointer_index = i + _reg_binding(i, '%d' % c[0]) + + @manager.registry.add_binding(Keys.Enter, eager=True) + def set_answer(event): + ic.answered = True + event.cli.set_return_value(ic.get_selected_value()) + + return Application( + layout=layout, + key_bindings_registry=manager.registry, + mouse_support=True, + style=style + ) diff --git a/src/libs/PyInquirer/separator.py b/src/libs/PyInquirer/separator.py new file mode 100644 index 0000000..663aba7 --- /dev/null +++ b/src/libs/PyInquirer/separator.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +""" +Used to space/separate choices group +""" + + +class Separator(object): + line = '-' * 15 + + def __init__(self, line=None): + if line: + self.line = line + + def __str__(self): + return self.line diff --git a/src/libs/PyInquirer/utils.py b/src/libs/PyInquirer/utils.py new file mode 100644 index 0000000..91d8a9c --- /dev/null +++ b/src/libs/PyInquirer/utils.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +from __future__ import print_function +import json +import sys +from pprint import pprint + +from pygments import highlight, lexers, formatters + +__version__ = '0.1.2' + +PY3 = sys.version_info[0] >= 3 + + +def format_json(data): + return json.dumps(data, sort_keys=True, indent=4) + + +def colorize_json(data): + if PY3: + if isinstance(data, bytes): + data = data.decode('UTF-8') + else: + if not isinstance(data, unicode): + data = unicode(data, 'UTF-8') + colorful_json = highlight(data, + lexers.JsonLexer(), + formatters.TerminalFormatter()) + return colorful_json + + +def print_json(data): + #colorful_json = highlight(unicode(format_json(data), 'UTF-8'), + # lexers.JsonLexer(), + # formatters.TerminalFormatter()) + pprint(colorize_json(format_json(data))) diff --git a/src/libs/prompt_toolkit/__init__.py b/src/libs/prompt_toolkit/__init__.py new file mode 100644 index 0000000..13a098f --- /dev/null +++ b/src/libs/prompt_toolkit/__init__.py @@ -0,0 +1,22 @@ +""" +libs.prompt_toolkit +============== + +Author: Jonathan Slenders + +Description: libs.prompt_toolkit is a Library for building powerful interactive + command lines in Python. It can be a replacement for GNU + readline, but it can be much more than that. + +See the examples directory to learn about the usage. + +Probably, to get started, you meight also want to have a look at +`libs.prompt_toolkit.shortcuts.prompt`. +""" +from .interface import CommandLineInterface +from .application import AbortAction, Application +from .shortcuts import prompt, prompt_async + + +# Don't forget to update in `docs/conf.py`! +__version__ = '1.0.14' diff --git a/src/libs/prompt_toolkit/application.py b/src/libs/prompt_toolkit/application.py new file mode 100644 index 0000000..5df8cfd --- /dev/null +++ b/src/libs/prompt_toolkit/application.py @@ -0,0 +1,192 @@ +from __future__ import unicode_literals + +from .buffer import Buffer, AcceptAction +from .buffer_mapping import BufferMapping +from .clipboard import Clipboard, InMemoryClipboard +from .enums import DEFAULT_BUFFER, EditingMode +from .filters import CLIFilter, to_cli_filter +from .key_binding.bindings.basic import load_basic_bindings +from .key_binding.bindings.emacs import load_emacs_bindings +from .key_binding.bindings.vi import load_vi_bindings +from .key_binding.registry import BaseRegistry +from .key_binding.defaults import load_key_bindings +from .layout import Window +from .layout.containers import Container +from .layout.controls import BufferControl +from .styles import DEFAULT_STYLE, Style +import six + +__all__ = ( + 'AbortAction', + 'Application', +) + + +class AbortAction(object): + """ + Actions to take on an Exit or Abort exception. + """ + RETRY = 'retry' + RAISE_EXCEPTION = 'raise-exception' + RETURN_NONE = 'return-none' + + _all = (RETRY, RAISE_EXCEPTION, RETURN_NONE) + + +class Application(object): + """ + Application class to be passed to a + :class:`~libs.prompt_toolkit.interface.CommandLineInterface`. + + This contains all customizable logic that is not I/O dependent. + (So, what is independent of event loops, input and output.) + + This way, such an :class:`.Application` can run easily on several + :class:`~libs.prompt_toolkit.interface.CommandLineInterface` instances, each + with a different I/O backends. that runs for instance over telnet, SSH or + any other I/O backend. + + :param layout: A :class:`~libs.prompt_toolkit.layout.containers.Container` instance. + :param buffer: A :class:`~libs.prompt_toolkit.buffer.Buffer` instance for the default buffer. + :param initial_focussed_buffer: Name of the buffer that is focussed during start-up. + :param key_bindings_registry: + :class:`~libs.prompt_toolkit.key_binding.registry.BaseRegistry` instance for + the key bindings. + :param clipboard: :class:`~libs.prompt_toolkit.clipboard.base.Clipboard` to use. + :param on_abort: What to do when Control-C is pressed. + :param on_exit: What to do when Control-D is pressed. + :param use_alternate_screen: When True, run the application on the alternate screen buffer. + :param get_title: Callable that returns the current title to be displayed in the terminal. + :param erase_when_done: (bool) Clear the application output when it finishes. + :param reverse_vi_search_direction: Normally, in Vi mode, a '/' searches + forward and a '?' searches backward. In readline mode, this is usually + reversed. + + Filters: + + :param mouse_support: (:class:`~libs.prompt_toolkit.filters.CLIFilter` or + boolean). When True, enable mouse support. + :param paste_mode: :class:`~libs.prompt_toolkit.filters.CLIFilter` or boolean. + :param ignore_case: :class:`~libs.prompt_toolkit.filters.CLIFilter` or boolean. + :param editing_mode: :class:`~libs.prompt_toolkit.enums.EditingMode`. + + Callbacks (all of these should accept a + :class:`~libs.prompt_toolkit.interface.CommandLineInterface` object as input.) + + :param on_input_timeout: Called when there is no input for x seconds. + (Fired when any eventloop.onInputTimeout is fired.) + :param on_start: Called when reading input starts. + :param on_stop: Called when reading input ends. + :param on_reset: Called during reset. + :param on_buffer_changed: Called when the content of a buffer has been changed. + :param on_initialize: Called after the + :class:`~libs.prompt_toolkit.interface.CommandLineInterface` initializes. + :param on_render: Called right after rendering. + :param on_invalidate: Called when the UI has been invalidated. + """ + def __init__(self, layout=None, buffer=None, buffers=None, + initial_focussed_buffer=DEFAULT_BUFFER, + style=None, + key_bindings_registry=None, clipboard=None, + on_abort=AbortAction.RAISE_EXCEPTION, on_exit=AbortAction.RAISE_EXCEPTION, + use_alternate_screen=False, mouse_support=False, + get_title=None, + + paste_mode=False, ignore_case=False, editing_mode=EditingMode.EMACS, + erase_when_done=False, + reverse_vi_search_direction=False, + + on_input_timeout=None, on_start=None, on_stop=None, + on_reset=None, on_initialize=None, on_buffer_changed=None, + on_render=None, on_invalidate=None): + + paste_mode = to_cli_filter(paste_mode) + ignore_case = to_cli_filter(ignore_case) + mouse_support = to_cli_filter(mouse_support) + reverse_vi_search_direction = to_cli_filter(reverse_vi_search_direction) + + assert layout is None or isinstance(layout, Container) + assert buffer is None or isinstance(buffer, Buffer) + assert buffers is None or isinstance(buffers, (dict, BufferMapping)) + assert key_bindings_registry is None or isinstance(key_bindings_registry, BaseRegistry) + assert clipboard is None or isinstance(clipboard, Clipboard) + assert on_abort in AbortAction._all + assert on_exit in AbortAction._all + assert isinstance(use_alternate_screen, bool) + assert get_title is None or callable(get_title) + assert isinstance(paste_mode, CLIFilter) + assert isinstance(ignore_case, CLIFilter) + assert isinstance(editing_mode, six.string_types) + assert on_input_timeout is None or callable(on_input_timeout) + assert style is None or isinstance(style, Style) + assert isinstance(erase_when_done, bool) + + assert on_start is None or callable(on_start) + assert on_stop is None or callable(on_stop) + assert on_reset is None or callable(on_reset) + assert on_buffer_changed is None or callable(on_buffer_changed) + assert on_initialize is None or callable(on_initialize) + assert on_render is None or callable(on_render) + assert on_invalidate is None or callable(on_invalidate) + + self.layout = layout or Window(BufferControl()) + + # Make sure that the 'buffers' dictionary is a BufferMapping. + # NOTE: If no buffer is given, we create a default Buffer, with IGNORE as + # default accept_action. This is what makes sense for most users + # creating full screen applications. Doing nothing is the obvious + # default. Those creating a REPL would use the shortcuts module that + # passes in RETURN_DOCUMENT. + self.buffer = buffer or Buffer(accept_action=AcceptAction.IGNORE) + if not buffers or not isinstance(buffers, BufferMapping): + self.buffers = BufferMapping(buffers, initial=initial_focussed_buffer) + else: + self.buffers = buffers + + if buffer: + self.buffers[DEFAULT_BUFFER] = buffer + + self.initial_focussed_buffer = initial_focussed_buffer + + self.style = style or DEFAULT_STYLE + + if key_bindings_registry is None: + key_bindings_registry = load_key_bindings() + + if get_title is None: + get_title = lambda: None + + self.key_bindings_registry = key_bindings_registry + self.clipboard = clipboard or InMemoryClipboard() + self.on_abort = on_abort + self.on_exit = on_exit + self.use_alternate_screen = use_alternate_screen + self.mouse_support = mouse_support + self.get_title = get_title + + self.paste_mode = paste_mode + self.ignore_case = ignore_case + self.editing_mode = editing_mode + self.erase_when_done = erase_when_done + self.reverse_vi_search_direction = reverse_vi_search_direction + + def dummy_handler(cli): + " Dummy event handler. " + + self.on_input_timeout = on_input_timeout or dummy_handler + self.on_start = on_start or dummy_handler + self.on_stop = on_stop or dummy_handler + self.on_reset = on_reset or dummy_handler + self.on_initialize = on_initialize or dummy_handler + self.on_buffer_changed = on_buffer_changed or dummy_handler + self.on_render = on_render or dummy_handler + self.on_invalidate = on_invalidate or dummy_handler + + # List of 'extra' functions to execute before a CommandLineInterface.run. + # Note: It's important to keep this here, and not in the + # CommandLineInterface itself. shortcuts.run_application creates + # a new Application instance everytime. (Which is correct, it + # could be that we want to detach from one IO backend and attach + # the UI on a different backend.) But important is to keep as + # much state as possible between runs. + self.pre_run_callables = [] diff --git a/src/libs/prompt_toolkit/auto_suggest.py b/src/libs/prompt_toolkit/auto_suggest.py new file mode 100644 index 0000000..d785801 --- /dev/null +++ b/src/libs/prompt_toolkit/auto_suggest.py @@ -0,0 +1,88 @@ +""" +`Fish-style `_ like auto-suggestion. + +While a user types input in a certain buffer, suggestions are generated +(asynchronously.) Usually, they are displayed after the input. When the cursor +presses the right arrow and the cursor is at the end of the input, the +suggestion will be inserted. +""" +from __future__ import unicode_literals +from abc import ABCMeta, abstractmethod +from six import with_metaclass + +from .filters import to_cli_filter + +__all__ = ( + 'Suggestion', + 'AutoSuggest', + 'AutoSuggestFromHistory', + 'ConditionalAutoSuggest', +) + + +class Suggestion(object): + """ + Suggestion returned by an auto-suggest algorithm. + + :param text: The suggestion text. + """ + def __init__(self, text): + self.text = text + + def __repr__(self): + return 'Suggestion(%s)' % self.text + + +class AutoSuggest(with_metaclass(ABCMeta, object)): + """ + Base class for auto suggestion implementations. + """ + @abstractmethod + def get_suggestion(self, cli, buffer, document): + """ + Return `None` or a :class:`.Suggestion` instance. + + We receive both ``buffer`` and ``document``. The reason is that auto + suggestions are retrieved asynchronously. (Like completions.) The + buffer text could be changed in the meantime, but ``document`` contains + the buffer document like it was at the start of the auto suggestion + call. So, from here, don't access ``buffer.text``, but use + ``document.text`` instead. + + :param buffer: The :class:`~libs.prompt_toolkit.buffer.Buffer` instance. + :param document: The :class:`~libs.prompt_toolkit.document.Document` instance. + """ + + +class AutoSuggestFromHistory(AutoSuggest): + """ + Give suggestions based on the lines in the history. + """ + def get_suggestion(self, cli, buffer, document): + history = buffer.history + + # Consider only the last line for the suggestion. + text = document.text.rsplit('\n', 1)[-1] + + # Only create a suggestion when this is not an empty line. + if text.strip(): + # Find first matching line in history. + for string in reversed(list(history)): + for line in reversed(string.splitlines()): + if line.startswith(text): + return Suggestion(line[len(text):]) + + +class ConditionalAutoSuggest(AutoSuggest): + """ + Auto suggest that can be turned on and of according to a certain condition. + """ + def __init__(self, auto_suggest, filter): + assert isinstance(auto_suggest, AutoSuggest) + + self.auto_suggest = auto_suggest + self.filter = to_cli_filter(filter) + + def get_suggestion(self, cli, buffer, document): + if self.filter(cli): + return self.auto_suggest.get_suggestion(cli, buffer, document) diff --git a/src/libs/prompt_toolkit/buffer.py b/src/libs/prompt_toolkit/buffer.py new file mode 100644 index 0000000..5bc04f6 --- /dev/null +++ b/src/libs/prompt_toolkit/buffer.py @@ -0,0 +1,1415 @@ +""" +Data structures for the Buffer. +It holds the text, cursor position, history, etc... +""" +from __future__ import unicode_literals + +from .auto_suggest import AutoSuggest +from .clipboard import ClipboardData +from .completion import Completer, Completion, CompleteEvent +from .document import Document +from .enums import IncrementalSearchDirection +from .filters import to_simple_filter +from .history import History, InMemoryHistory +from .search_state import SearchState +from .selection import SelectionType, SelectionState, PasteMode +from .utils import Event +from .cache import FastDictCache +from .validation import ValidationError + +from six.moves import range + +import os +import re +import shlex +import six +import subprocess +import tempfile + +__all__ = ( + 'EditReadOnlyBuffer', + 'AcceptAction', + 'Buffer', + 'indent', + 'unindent', + 'reshape_text', +) + + +class EditReadOnlyBuffer(Exception): + " Attempt editing of read-only :class:`.Buffer`. " + + +class AcceptAction(object): + """ + What to do when the input is accepted by the user. + (When Enter was pressed in the command line.) + + :param handler: (optional) A callable which takes a + :class:`~libs.prompt_toolkit.interface.CommandLineInterface` and + :class:`~libs.prompt_toolkit.document.Document`. It is called when the user + accepts input. + """ + def __init__(self, handler=None): + assert handler is None or callable(handler) + self.handler = handler + + @classmethod + def run_in_terminal(cls, handler, render_cli_done=False): + """ + Create an :class:`.AcceptAction` that runs the given handler in the + terminal. + + :param render_cli_done: When True, render the interface in the 'Done' + state first, then execute the function. If False, erase the + interface instead. + """ + def _handler(cli, buffer): + cli.run_in_terminal(lambda: handler(cli, buffer), render_cli_done=render_cli_done) + return AcceptAction(handler=_handler) + + @property + def is_returnable(self): + """ + True when there is something handling accept. + """ + return bool(self.handler) + + def validate_and_handle(self, cli, buffer): + """ + Validate buffer and handle the accept action. + """ + if buffer.validate(): + if self.handler: + self.handler(cli, buffer) + + buffer.append_to_history() + + +def _return_document_handler(cli, buffer): + # Set return value. + cli.set_return_value(buffer.document) + + # Make sure that if we run this UI again, that we reset this buffer, next + # time. + def reset_this_buffer(): + buffer.reset() + cli.pre_run_callables.append(reset_this_buffer) + + +AcceptAction.RETURN_DOCUMENT = AcceptAction(_return_document_handler) +AcceptAction.IGNORE = AcceptAction(handler=None) + + +class ValidationState(object): + " The validation state of a buffer. This is set after the validation. " + VALID = 'VALID' + INVALID = 'INVALID' + UNKNOWN = 'UNKNOWN' + + +class CompletionState(object): + """ + Immutable class that contains a completion state. + """ + def __init__(self, original_document, current_completions=None, complete_index=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 = complete_index # Position in the `_completions` array. + + def __repr__(self): + return '%s(%r, <%r> completions, index=%r)' % ( + self.__class__.__name__, + self.original_document, len(self.current_completions), self.complete_index) + + def go_to_index(self, index): + """ + Create a new :class:`.CompletionState` object with the new index. + """ + return CompletionState(self.original_document, self.current_completions, complete_index=index) + + def new_text_and_position(self): + """ + Return (new_text, new_cursor_position) for this completion. + """ + if self.complete_index is None: + return self.original_document.text, self.original_document.cursor_position + else: + original_text_before_cursor = self.original_document.text_before_cursor + original_text_after_cursor = self.original_document.text_after_cursor + + c = self.current_completions[self.complete_index] + if c.start_position == 0: + before = original_text_before_cursor + else: + before = original_text_before_cursor[:c.start_position] + + new_text = before + c.text + original_text_after_cursor + new_cursor_position = len(before) + len(c.text) + return new_text, new_cursor_position + + @property + def current_completion(self): + """ + Return the current completion, or return `None` when no completion is + selected. + """ + if self.complete_index is not None: + return self.current_completions[self.complete_index] + + +_QUOTED_WORDS_RE = re.compile(r"""(\s+|".*?"|'.*?')""") + + +class YankNthArgState(object): + """ + For yank-last-arg/yank-nth-arg: Keep track of where we are in the history. + """ + def __init__(self, history_position=0, n=-1, previous_inserted_word=''): + self.history_position = history_position + self.previous_inserted_word = previous_inserted_word + self.n = n + + def __repr__(self): + return '%s(history_position=%r, n=%r, previous_inserted_word=%r)' % ( + self.__class__.__name__, self.history_position, self.n, + self.previous_inserted_word) + + +class Buffer(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 and the completion state. + + :param completer: :class:`~libs.prompt_toolkit.completion.Completer` instance. + :param history: :class:`~libs.prompt_toolkit.history.History` instance. + :param tempfile_suffix: Suffix to be appended to the tempfile for the 'open + in editor' function. + + Events: + + :param on_text_changed: When the buffer text changes. (Callable on None.) + :param on_text_insert: When new text is inserted. (Callable on None.) + :param on_cursor_position_changed: When the cursor moves. (Callable on None.) + + Filters: + + :param is_multiline: :class:`~libs.prompt_toolkit.filters.SimpleFilter` to + indicate whether we should consider this buffer a multiline input. If + so, key bindings can decide to insert newlines when pressing [Enter]. + (Instead of accepting the input.) + :param complete_while_typing: :class:`~libs.prompt_toolkit.filters.SimpleFilter` + instance. Decide whether or not to do asynchronous autocompleting while + typing. + :param enable_history_search: :class:`~libs.prompt_toolkit.filters.SimpleFilter` + to indicate when up-arrow partial string matching is enabled. It is + adviced to not enable this at the same time as `complete_while_typing`, + because when there is an autocompletion found, the up arrows usually + browse through the completions, rather than through the history. + :param read_only: :class:`~libs.prompt_toolkit.filters.SimpleFilter`. When True, + changes will not be allowed. + """ + def __init__(self, completer=None, auto_suggest=None, history=None, + validator=None, tempfile_suffix='', + is_multiline=False, complete_while_typing=False, + enable_history_search=False, initial_document=None, + accept_action=AcceptAction.IGNORE, read_only=False, + on_text_changed=None, on_text_insert=None, on_cursor_position_changed=None): + + # Accept both filters and booleans as input. + enable_history_search = to_simple_filter(enable_history_search) + is_multiline = to_simple_filter(is_multiline) + complete_while_typing = to_simple_filter(complete_while_typing) + read_only = to_simple_filter(read_only) + + # Validate input. + assert completer is None or isinstance(completer, Completer) + assert auto_suggest is None or isinstance(auto_suggest, AutoSuggest) + assert history is None or isinstance(history, History) + assert on_text_changed is None or callable(on_text_changed) + assert on_text_insert is None or callable(on_text_insert) + assert on_cursor_position_changed is None or callable(on_cursor_position_changed) + + self.completer = completer + self.auto_suggest = auto_suggest + self.validator = validator + self.tempfile_suffix = tempfile_suffix + self.accept_action = accept_action + + # Filters. (Usually, used by the key bindings to drive the buffer.) + self.is_multiline = is_multiline + self.complete_while_typing = complete_while_typing + self.enable_history_search = enable_history_search + self.read_only = read_only + + # Text width. (For wrapping, used by the Vi 'gq' operator.) + self.text_width = 0 + + #: The command buffer history. + # Note that we shouldn't use a lazy 'or' here. bool(history) could be + # False when empty. + self.history = InMemoryHistory() if history is None else history + + self.__cursor_position = 0 + + # Events + self.on_text_changed = Event(self, on_text_changed) + self.on_text_insert = Event(self, on_text_insert) + self.on_cursor_position_changed = Event(self, on_cursor_position_changed) + + # Document cache. (Avoid creating new Document instances.) + self._document_cache = FastDictCache(Document, size=10) + + self.reset(initial_document=initial_document) + + def reset(self, initial_document=None, append_to_history=False): + """ + :param append_to_history: Append current input to history first. + """ + assert initial_document is None or isinstance(initial_document, Document) + + if append_to_history: + self.append_to_history() + + initial_document = initial_document or Document() + + self.__cursor_position = initial_document.cursor_position + + # `ValidationError` instance. (Will be set when the input is wrong.) + self.validation_error = None + self.validation_state = ValidationState.UNKNOWN + + # State of the selection. + self.selection_state = None + + # Multiple cursor mode. (When we press 'I' or 'A' in visual-block mode, + # we can insert text on multiple lines at once. This is implemented by + # using multiple cursors.) + self.multiple_cursor_positions = [] + + # When doing consecutive up/down movements, prefer to stay at this column. + self.preferred_column = None + + # State of complete browser + self.complete_state = None # For interactive completion through Ctrl-N/Ctrl-P. + + # State of Emacs yank-nth-arg completion. + self.yank_nth_arg_state = None # for yank-nth-arg. + + # Remember the document that we had *right before* the last paste + # operation. This is used for rotating through the kill ring. + self.document_before_paste = None + + # Current suggestion. + self.suggestion = None + + # The history search text. (Used for filtering the history when we + # browse through it.) + self.history_search_text = None + + # Undo/redo stacks + self._undo_stack = [] # Stack of (text, cursor_position) + self._redo_stack = [] + + #: 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_document.text) + self.__working_index = len(self._working_lines) - 1 + + # + + def _set_text(self, value): + """ set text at current working_index. Return whether it changed. """ + working_index = self.working_index + working_lines = self._working_lines + + original_value = working_lines[working_index] + working_lines[working_index] = value + + # Return True when this text has been changed. + if len(value) != len(original_value): + # For Python 2, it seems that when two strings have a different + # length and one is a prefix of the other, Python still scans + # character by character to see whether the strings are different. + # (Some benchmarking showed significant differences for big + # documents. >100,000 of lines.) + return True + elif value != original_value: + return True + return False + + def _set_cursor_position(self, value): + """ Set cursor position. Return whether it changed. """ + original_position = self.__cursor_position + self.__cursor_position = max(0, value) + + return value != original_position + + @property + def text(self): + return self._working_lines[self.working_index] + + @text.setter + def text(self, value): + """ + Setting text. (When doing this, make sure that the cursor_position is + valid for this text. text/cursor_position should be consistent at any time, + otherwise set a Document instead.) + """ + assert isinstance(value, six.text_type), 'Got %r' % value + assert self.cursor_position <= len(value) + + # Don't allow editing of read-only buffers. + if self.read_only(): + raise EditReadOnlyBuffer() + + changed = self._set_text(value) + + if changed: + self._text_changed() + + # Reset history search text. + self.history_search_text = None + + @property + def cursor_position(self): + return self.__cursor_position + + @cursor_position.setter + def cursor_position(self, value): + """ + Setting cursor position. + """ + assert isinstance(value, int) + assert value <= len(self.text) + + changed = self._set_cursor_position(value) + + if changed: + self._cursor_position_changed() + + @property + def working_index(self): + return self.__working_index + + @working_index.setter + def working_index(self, value): + if self.__working_index != value: + self.__working_index = value + self._text_changed() + + def _text_changed(self): + # Remove any validation errors and complete state. + self.validation_error = None + self.validation_state = ValidationState.UNKNOWN + self.complete_state = None + self.yank_nth_arg_state = None + self.document_before_paste = None + self.selection_state = None + self.suggestion = None + self.preferred_column = None + + # fire 'on_text_changed' event. + self.on_text_changed.fire() + + def _cursor_position_changed(self): + # Remove any validation errors and complete state. + self.validation_error = None + self.validation_state = ValidationState.UNKNOWN + self.complete_state = None + self.yank_nth_arg_state = None + self.document_before_paste = None + + # Unset preferred_column. (Will be set after the cursor movement, if + # required.) + self.preferred_column = None + + # Note that the cursor position can change if we have a selection the + # new position of the cursor determines the end of the selection. + + # fire 'on_cursor_position_changed' event. + self.on_cursor_position_changed.fire() + + @property + def document(self): + """ + Return :class:`~libs.prompt_toolkit.document.Document` instance from the + current text, cursor position and selection state. + """ + return self._document_cache[ + self.text, self.cursor_position, self.selection_state] + + @document.setter + def document(self, value): + """ + Set :class:`~libs.prompt_toolkit.document.Document` instance. + + This will set both the text and cursor position at the same time, but + atomically. (Change events will be triggered only after both have been set.) + """ + self.set_document(value) + + def set_document(self, value, bypass_readonly=False): + """ + Set :class:`~libs.prompt_toolkit.document.Document` instance. Like the + ``document`` property, but accept an ``bypass_readonly`` argument. + + :param bypass_readonly: When True, don't raise an + :class:`.EditReadOnlyBuffer` exception, even + when the buffer is read-only. + """ + assert isinstance(value, Document) + + # Don't allow editing of read-only buffers. + if not bypass_readonly and self.read_only(): + raise EditReadOnlyBuffer() + + # Set text and cursor position first. + text_changed = self._set_text(value.text) + cursor_position_changed = self._set_cursor_position(value.cursor_position) + + # Now handle change events. (We do this when text/cursor position is + # both set and consistent.) + if text_changed: + self._text_changed() + + if cursor_position_changed: + self._cursor_position_changed() + + # End of + + def save_to_undo_stack(self, clear_redo_stack=True): + """ + Safe current state (input text and cursor position), so that we can + restore it by calling undo. + """ + # Safe if the text is different from the text at the top of the stack + # is different. If the text is the same, just update the cursor position. + if self._undo_stack and self._undo_stack[-1][0] == self.text: + self._undo_stack[-1] = (self._undo_stack[-1][0], self.cursor_position) + else: + self._undo_stack.append((self.text, self.cursor_position)) + + # Saving anything to the undo stack, clears the redo stack. + if clear_redo_stack: + self._redo_stack = [] + + def transform_lines(self, line_index_iterator, 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:: + + new_text = transform_lines(range(5,10), lambda text: text.upper()) + + :param line_index_iterator: Iterator of line numbers (int) + :param transform_callback: callable that takes the original text of a + line, and return the new text for this line. + + :returns: The new text. + """ + # Split lines + lines = self.text.split('\n') + + # Apply transformation + for index in line_index_iterator: + try: + lines[index] = transform_callback(lines[index]) + except IndexError: + pass + + return '\n'.join(lines) + + def transform_current_line(self, transform_callback): + """ + Apply the given transformation function to the current line. + + :param transform_callback: callable that takes a string and return a new string. + """ + document = self.document + a = document.cursor_position + document.get_start_of_line_position() + b = document.cursor_position + document.get_end_of_line_position() + self.text = ( + document.text[:a] + + transform_callback(document.text[a:b]) + + document.text[b:]) + + def transform_region(self, from_, to, transform_callback): + """ + Transform a part of the input string. + + :param from_: (int) start position. + :param to: (int) end position. + :param transform_callback: Callable which accepts a string and returns + the transformed string. + """ + assert from_ < to + + self.text = ''.join([ + self.text[:from_] + + transform_callback(self.text[from_:to]) + + self.text[to:] + ]) + + def cursor_left(self, count=1): + self.cursor_position += self.document.get_cursor_left_position(count=count) + + def cursor_right(self, count=1): + self.cursor_position += self.document.get_cursor_right_position(count=count) + + def cursor_up(self, count=1): + """ (for multiline edit). Move cursor to the previous line. """ + original_column = self.preferred_column or self.document.cursor_position_col + self.cursor_position += self.document.get_cursor_up_position( + count=count, preferred_column=original_column) + + # Remember the original column for the next up/down movement. + self.preferred_column = original_column + + def cursor_down(self, count=1): + """ (for multiline edit). Move cursor to the next line. """ + original_column = self.preferred_column or self.document.cursor_position_col + self.cursor_position += self.document.get_cursor_down_position( + count=count, preferred_column=original_column) + + # Remember the original column for the next up/down movement. + self.preferred_column = original_column + + def auto_up(self, count=1, go_to_start_of_line_if_history_changes=False): + """ + If we're not on the first line (of a multiline input) go a line up, + otherwise go back in history. (If nothing is selected.) + """ + if self.complete_state: + self.complete_previous(count=count) + elif self.document.cursor_position_row > 0: + self.cursor_up(count=count) + elif not self.selection_state: + self.history_backward(count=count) + + # Go to the start of the line? + if go_to_start_of_line_if_history_changes: + self.cursor_position += self.document.get_start_of_line_position() + + def auto_down(self, count=1, go_to_start_of_line_if_history_changes=False): + """ + If we're not on the last line (of a multiline input) go a line down, + otherwise go forward in history. (If nothing is selected.) + """ + if self.complete_state: + self.complete_next(count=count) + elif self.document.cursor_position_row < self.document.line_count - 1: + self.cursor_down(count=count) + elif not self.selection_state: + self.history_forward(count=count) + + # Go to the start of the line? + if go_to_start_of_line_if_history_changes: + self.cursor_position += self.document.get_start_of_line_position() + + def delete_before_cursor(self, count=1): + """ + Delete specified number of characters before cursor and return the + deleted text. + """ + assert count >= 0 + deleted = '' + + if self.cursor_position > 0: + deleted = self.text[self.cursor_position - count:self.cursor_position] + + new_text = self.text[:self.cursor_position - count] + self.text[self.cursor_position:] + new_cursor_position = self.cursor_position - len(deleted) + + # Set new Document atomically. + self.document = Document(new_text, new_cursor_position) + + return deleted + + def delete(self, count=1): + """ + Delete specified number of characters and Return the deleted text. + """ + if self.cursor_position < len(self.text): + deleted = self.document.text_after_cursor[:count] + self.text = self.text[:self.cursor_position] + \ + self.text[self.cursor_position + len(deleted):] + return deleted + else: + return '' + + def join_next_line(self, separator=' '): + """ + Join the next line to the current one by deleting the line ending after + the current line. + """ + if not self.document.on_last_line: + self.cursor_position += self.document.get_end_of_line_position() + self.delete() + + # Remove spaces. + self.text = (self.document.text_before_cursor + separator + + self.document.text_after_cursor.lstrip(' ')) + + def join_selected_lines(self, separator=' '): + """ + Join the selected lines. + """ + assert self.selection_state + + # Get lines. + from_, to = sorted([self.cursor_position, self.selection_state.original_cursor_position]) + + before = self.text[:from_] + lines = self.text[from_:to].splitlines() + after = self.text[to:] + + # Replace leading spaces with just one space. + lines = [l.lstrip(' ') + separator for l in lines] + + # Set new document. + self.document = Document(text=before + ''.join(lines) + after, + cursor_position=len(before + ''.join(lines[:-1])) - 1) + + def swap_characters_before_cursor(self): + """ + Swap the last two characters before the cursor. + """ + pos = self.cursor_position + + if pos >= 2: + a = self.text[pos - 2] + b = self.text[pos - 1] + + self.text = self.text[:pos-2] + b + a + self.text[pos:] + + def go_to_history(self, index): + """ + Go to this item in the history. + """ + if index < len(self._working_lines): + self.working_index = index + self.cursor_position = len(self.text) + + def complete_next(self, count=1, disable_wrap_around=False): + """ + Browse to the next completions. + (Does nothing if there are no completion.) + """ + if self.complete_state: + 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 + + if disable_wrap_around: + return + else: + index = min(completions_count-1, self.complete_state.complete_index + count) + self.go_to_completion(index) + + def complete_previous(self, count=1, disable_wrap_around=False): + """ + Browse to the previous completions. + (Does nothing if there are no completion.) + """ + if self.complete_state: + if self.complete_state.complete_index == 0: + index = None + + if disable_wrap_around: + return + elif self.complete_state.complete_index is None: + index = len(self.complete_state.current_completions) - 1 + else: + index = max(0, self.complete_state.complete_index - count) + + self.go_to_completion(index) + + def cancel_completion(self): + """ + Cancel completion, go back to the original text. + """ + if self.complete_state: + self.go_to_completion(None) + self.complete_state = None + + def set_completions(self, completions, go_to_first=True, go_to_last=False): + """ + Start completions. (Generate list of completions and initialize.) + """ + assert not (go_to_first and go_to_last) + + # Generate list of all completions. + if completions is None: + if self.completer: + completions = list(self.completer.get_completions( + self.document, + CompleteEvent(completion_requested=True) + )) + else: + completions = [] + + # Set `complete_state`. + if completions: + self.complete_state = CompletionState( + original_document=self.document, + current_completions=completions) + if go_to_first: + self.go_to_completion(0) + elif go_to_last: + self.go_to_completion(len(completions) - 1) + else: + self.go_to_completion(None) + + else: + self.complete_state = None + + def start_history_lines_completion(self): + """ + Start a completion based on all the other lines in the document and the + history. + """ + found_completions = set() + completions = [] + + # For every line of the whole history, find matches with the current line. + current_line = self.document.current_line_before_cursor.lstrip() + + for i, string in enumerate(self._working_lines): + for j, l in enumerate(string.split('\n')): + l = l.strip() + if l and l.startswith(current_line): + # When a new line has been found. + if l not in found_completions: + found_completions.add(l) + + # Create completion. + if i == self.working_index: + display_meta = "Current, line %s" % (j+1) + else: + display_meta = "History %s, line %s" % (i+1, j+1) + + completions.append(Completion( + l, + start_position=-len(current_line), + display_meta=display_meta)) + + self.set_completions(completions=completions[::-1]) + + def go_to_completion(self, index): + """ + Select a completion from the list of current completions. + """ + assert index is None or isinstance(index, int) + assert self.complete_state + + # Set new completion + state = self.complete_state.go_to_index(index) + + # Set text/cursor position + new_text, new_cursor_position = state.new_text_and_position() + self.document = Document(new_text, new_cursor_position) + + # (changing text/cursor position will unset complete_state.) + self.complete_state = state + + def apply_completion(self, completion): + """ + Insert a given completion. + """ + assert isinstance(completion, Completion) + + # If there was already a completion active, cancel that one. + if self.complete_state: + self.go_to_completion(None) + self.complete_state = None + + # Insert text from the given completion. + self.delete_before_cursor(-completion.start_position) + self.insert_text(completion.text) + + def _set_history_search(self): + """ Set `history_search_text`. """ + if self.enable_history_search(): + if self.history_search_text is None: + self.history_search_text = self.text + else: + self.history_search_text = None + + def _history_matches(self, i): + """ + True when the current entry matches the history search. + (when we don't have history search, it's also True.) + """ + return (self.history_search_text is None or + self._working_lines[i].startswith(self.history_search_text)) + + def history_forward(self, count=1): + """ + Move forwards through the history. + + :param count: Amount of items to move forward. + """ + self._set_history_search() + + # Go forward in history. + found_something = False + + for i in range(self.working_index + 1, len(self._working_lines)): + if self._history_matches(i): + self.working_index = i + count -= 1 + found_something = True + if count == 0: + break + + # If we found an entry, move cursor to the end of the first line. + if found_something: + self.cursor_position = 0 + self.cursor_position += self.document.get_end_of_line_position() + + def history_backward(self, count=1): + """ + Move backwards through history. + """ + self._set_history_search() + + # Go back in history. + found_something = False + + for i in range(self.working_index - 1, -1, -1): + if self._history_matches(i): + self.working_index = i + count -= 1 + found_something = True + if count == 0: + break + + # If we move to another entry, move cursor to the end of the line. + if found_something: + self.cursor_position = len(self.text) + + def yank_nth_arg(self, n=None, _yank_last_arg=False): + """ + Pick nth word from previous history entry (depending on current + `yank_nth_arg_state`) and insert it at current position. Rotate through + history if called repeatedly. If no `n` has been given, take the first + argument. (The second word.) + + :param n: (None or int), The index of the word from the previous line + to take. + """ + assert n is None or isinstance(n, int) + + if not len(self.history): + return + + # Make sure we have a `YankNthArgState`. + if self.yank_nth_arg_state is None: + state = YankNthArgState(n=-1 if _yank_last_arg else 1) + else: + state = self.yank_nth_arg_state + + if n is not None: + state.n = n + + # Get new history position. + new_pos = state.history_position - 1 + if -new_pos > len(self.history): + new_pos = -1 + + # Take argument from line. + line = self.history[new_pos] + + words = [w.strip() for w in _QUOTED_WORDS_RE.split(line)] + words = [w for w in words if w] + try: + word = words[state.n] + except IndexError: + word = '' + + # Insert new argument. + if state.previous_inserted_word: + self.delete_before_cursor(len(state.previous_inserted_word)) + self.insert_text(word) + + # Save state again for next completion. (Note that the 'insert' + # operation from above clears `self.yank_nth_arg_state`.) + state.previous_inserted_word = word + state.history_position = new_pos + self.yank_nth_arg_state = state + + def yank_last_arg(self, n=None): + """ + Like `yank_nth_arg`, but if no argument has been given, yank the last + word by default. + """ + self.yank_nth_arg(n=n, _yank_last_arg=True) + + def start_selection(self, selection_type=SelectionType.CHARACTERS): + """ + Take the current cursor position as the start of this selection. + """ + self.selection_state = SelectionState(self.cursor_position, selection_type) + + def copy_selection(self, _cut=False): + """ + Copy selected text and return :class:`.ClipboardData` instance. + """ + new_document, clipboard_data = self.document.cut_selection() + if _cut: + self.document = new_document + + self.selection_state = None + return clipboard_data + + def cut_selection(self): + """ + Delete selected text and return :class:`.ClipboardData` instance. + """ + return self.copy_selection(_cut=True) + + def paste_clipboard_data(self, data, paste_mode=PasteMode.EMACS, count=1): + """ + Insert the data from the clipboard. + """ + assert isinstance(data, ClipboardData) + assert paste_mode in (PasteMode.VI_BEFORE, PasteMode.VI_AFTER, PasteMode.EMACS) + + original_document = self.document + self.document = self.document.paste_clipboard_data(data, paste_mode=paste_mode, count=count) + + # Remember original document. This assignment should come at the end, + # because assigning to 'document' will erase it. + self.document_before_paste = original_document + + def newline(self, copy_margin=True): + """ + Insert a line ending at the current position. + """ + if copy_margin: + self.insert_text('\n' + self.document.leading_whitespace_in_current_line) + else: + self.insert_text('\n') + + def insert_line_above(self, copy_margin=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_position += self.document.get_start_of_line_position() + 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_position += self.document.get_end_of_line_position() + self.insert_text(insert) + + def insert_text(self, data, overwrite=False, move_cursor=True, fire_event=True): + """ + Insert characters at cursor position. + + :param fire_event: Fire `on_text_insert` event. This is mainly used to + trigger autocompletion while typing. + """ + # Original text & cursor position. + otext = self.text + ocpos = self.cursor_position + + # In insert/text mode. + if overwrite: + # Don't overwrite the newline itself. Just before the line ending, + # it should act like insert mode. + overwritten_text = otext[ocpos:ocpos + len(data)] + if '\n' in overwritten_text: + overwritten_text = overwritten_text[:overwritten_text.find('\n')] + + self.text = otext[:ocpos] + data + otext[ocpos + len(overwritten_text):] + else: + self.text = otext[:ocpos] + data + otext[ocpos:] + + if move_cursor: + self.cursor_position += len(data) + + # Fire 'on_text_insert' event. + if fire_event: + self.on_text_insert.fire() + + def undo(self): + # Pop from the undo-stack until we find a text that if different from + # the current text. (The current logic of `save_to_undo_stack` will + # cause that the top of the undo stack is usually the same as the + # current text, so in that case we have to pop twice.) + while self._undo_stack: + text, pos = self._undo_stack.pop() + + if text != self.text: + # Push current text to redo stack. + self._redo_stack.append((self.text, self.cursor_position)) + + # Set new text/cursor_position. + self.document = Document(text, cursor_position=pos) + break + + def redo(self): + if self._redo_stack: + # Copy current state on undo stack. + self.save_to_undo_stack(clear_redo_stack=False) + + # Pop state from redo stack. + text, pos = self._redo_stack.pop() + self.document = Document(text, cursor_position=pos) + + def validate(self): + """ + Returns `True` if valid. + """ + # Don't call the validator again, if it was already called for the + # current input. + if self.validation_state != ValidationState.UNKNOWN: + return self.validation_state == ValidationState.VALID + + # Validate first. If not valid, set validation exception. + if self.validator: + try: + self.validator.validate(self.document) + except ValidationError as e: + # Set cursor position (don't allow invalid values.) + cursor_position = e.cursor_position + self.cursor_position = min(max(0, cursor_position), len(self.text)) + + self.validation_state = ValidationState.INVALID + self.validation_error = e + return False + + self.validation_state = ValidationState.VALID + self.validation_error = None + return True + + def append_to_history(self): + """ + Append the current input to the history. + (Only if valid input.) + """ + # Validate first. If not valid, set validation exception. + if not self.validate(): + return + + # Save at the tail of the history. (But don't if the last entry the + # history is already the same.) + if self.text and (not len(self.history) or self.history[-1] != self.text): + self.history.append(self.text) + + def _search(self, search_state, include_current_position=False, count=1): + """ + Execute search. Return (working_index, cursor_position) tuple when this + search is applied. Returns `None` when this text cannot be found. + """ + assert isinstance(search_state, SearchState) + assert isinstance(count, int) and count > 0 + + text = search_state.text + direction = search_state.direction + ignore_case = search_state.ignore_case() + + def search_once(working_index, document): + """ + Do search one time. + Return (working_index, document) or `None` + """ + if direction == IncrementalSearchDirection.FORWARD: + # Try find at the current input. + new_index = document.find( + text, include_current_position=include_current_position, + ignore_case=ignore_case) + + if new_index is not None: + return (working_index, + Document(document.text, document.cursor_position + new_index)) + else: + # No match, go forward in the history. (Include len+1 to wrap around.) + # (Here we should always include all cursor positions, because + # it's a different line.) + for i in range(working_index + 1, len(self._working_lines) + 1): + i %= len(self._working_lines) + + document = Document(self._working_lines[i], 0) + new_index = document.find(text, include_current_position=True, + ignore_case=ignore_case) + if new_index is not None: + return (i, Document(document.text, new_index)) + else: + # Try find at the current input. + new_index = document.find_backwards( + text, ignore_case=ignore_case) + + if new_index is not None: + return (working_index, + Document(document.text, document.cursor_position + new_index)) + else: + # No match, go back in the history. (Include -1 to wrap around.) + for i in range(working_index - 1, -2, -1): + i %= len(self._working_lines) + + document = Document(self._working_lines[i], len(self._working_lines[i])) + new_index = document.find_backwards( + text, ignore_case=ignore_case) + if new_index is not None: + return (i, Document(document.text, len(document.text) + new_index)) + + # Do 'count' search iterations. + working_index = self.working_index + document = self.document + for _ in range(count): + result = search_once(working_index, document) + if result is None: + return # Nothing found. + else: + working_index, document = result + + return (working_index, document.cursor_position) + + def document_for_search(self, search_state): + """ + Return a :class:`~libs.prompt_toolkit.document.Document` instance that has + the text/cursor position for this search, if we would apply it. This + will be used in the + :class:`~libs.prompt_toolkit.layout.controls.BufferControl` to display + feedback while searching. + """ + search_result = self._search(search_state, include_current_position=True) + + if search_result is None: + return self.document + else: + working_index, cursor_position = search_result + + # Keep selection, when `working_index` was not changed. + if working_index == self.working_index: + selection = self.selection_state + else: + selection = None + + return Document(self._working_lines[working_index], + cursor_position, selection=selection) + + def get_search_position(self, search_state, include_current_position=True, count=1): + """ + Get the cursor position for this search. + (This operation won't change the `working_index`. It's won't go through + the history. Vi text objects can't span multiple items.) + """ + search_result = self._search( + search_state, include_current_position=include_current_position, count=count) + + if search_result is None: + return self.cursor_position + else: + working_index, cursor_position = search_result + return cursor_position + + def apply_search(self, search_state, include_current_position=True, count=1): + """ + Apply search. If something is found, set `working_index` and + `cursor_position`. + """ + search_result = self._search( + search_state, include_current_position=include_current_position, count=count) + + if search_result is not None: + working_index, cursor_position = search_result + self.working_index = working_index + self.cursor_position = cursor_position + + def exit_selection(self): + self.selection_state = None + + def open_in_editor(self, cli): + """ + Open code in editor. + + :param cli: :class:`~libs.prompt_toolkit.interface.CommandLineInterface` + instance. + """ + if self.read_only(): + raise EditReadOnlyBuffer() + + # Write to temporary file + descriptor, filename = tempfile.mkstemp(self.tempfile_suffix) + os.write(descriptor, self.text.encode('utf-8')) + os.close(descriptor) + + # Open in editor + # (We need to use `cli.run_in_terminal`, because not all editors go to + # the alternate screen buffer, and some could influence the cursor + # position.) + succes = cli.run_in_terminal(lambda: self._open_file_in_editor(filename)) + + # Read content again. + if succes: + with open(filename, 'rb') as f: + text = f.read().decode('utf-8') + + # Drop trailing newline. (Editors are supposed to add it at the + # end, but we don't need it.) + if text.endswith('\n'): + text = text[:-1] + + self.document = Document( + text=text, + cursor_position=len(text)) + + # Clean up temp file. + os.remove(filename) + + def _open_file_in_editor(self, filename): + """ + Call editor executable. + + Return True when we received a zero return code. + """ + # If the 'VISUAL' or 'EDITOR' environment variable has been set, use that. + # Otherwise, fall back to the first available editor that we can find. + visual = os.environ.get('VISUAL') + editor = os.environ.get('EDITOR') + + editors = [ + visual, + editor, + + # Order of preference. + '/usr/bin/editor', + '/usr/bin/nano', + '/usr/bin/pico', + '/usr/bin/vi', + '/usr/bin/emacs', + ] + + for e in editors: + if e: + try: + # Use 'shlex.split()', because $VISUAL can contain spaces + # and quotes. + returncode = subprocess.call(shlex.split(e) + [filename]) + return returncode == 0 + + except OSError: + # Executable does not exist, try the next one. + pass + + return False + + +def indent(buffer, from_row, to_row, count=1): + """ + Indent text of a :class:`.Buffer` object. + """ + current_row = buffer.document.cursor_position_row + line_range = range(from_row, to_row) + + # Apply transformation. + new_text = buffer.transform_lines(line_range, lambda l: ' ' * count + l) + buffer.document = Document( + new_text, + Document(new_text).translate_row_col_to_index(current_row, 0)) + + # Go to the start of the line. + buffer.cursor_position += buffer.document.get_start_of_line_position(after_whitespace=True) + + +def unindent(buffer, from_row, to_row, count=1): + """ + Unindent text of a :class:`.Buffer` object. + """ + current_row = buffer.document.cursor_position_row + line_range = range(from_row, to_row) + + def transform(text): + remove = ' ' * count + if text.startswith(remove): + return text[len(remove):] + else: + return text.lstrip() + + # Apply transformation. + new_text = buffer.transform_lines(line_range, transform) + buffer.document = Document( + new_text, + Document(new_text).translate_row_col_to_index(current_row, 0)) + + # Go to the start of the line. + buffer.cursor_position += buffer.document.get_start_of_line_position(after_whitespace=True) + + +def reshape_text(buffer, from_row, to_row): + """ + Reformat text, taking the width into account. + `to_row` is included. + (Vi 'gq' operator.) + """ + lines = buffer.text.splitlines(True) + lines_before = lines[:from_row] + lines_after = lines[to_row + 1:] + lines_to_reformat = lines[from_row:to_row + 1] + + if lines_to_reformat: + # Take indentation from the first line. + length = re.search(r'^\s*', lines_to_reformat[0]).end() + indent = lines_to_reformat[0][:length].replace('\n', '') + + # Now, take all the 'words' from the lines to be reshaped. + words = ''.join(lines_to_reformat).split() + + # And reshape. + width = (buffer.text_width or 80) - len(indent) + reshaped_text = [indent] + current_width = 0 + for w in words: + if current_width: + if len(w) + current_width + 1 > width: + reshaped_text.append('\n') + reshaped_text.append(indent) + current_width = 0 + else: + reshaped_text.append(' ') + current_width += 1 + + reshaped_text.append(w) + current_width += len(w) + + if reshaped_text[-1] != '\n': + reshaped_text.append('\n') + + # Apply result. + buffer.document = Document( + text=''.join(lines_before + reshaped_text + lines_after), + cursor_position=len(''.join(lines_before + reshaped_text))) diff --git a/src/libs/prompt_toolkit/buffer_mapping.py b/src/libs/prompt_toolkit/buffer_mapping.py new file mode 100644 index 0000000..fbc46f4 --- /dev/null +++ b/src/libs/prompt_toolkit/buffer_mapping.py @@ -0,0 +1,92 @@ +""" +The BufferMapping contains all the buffers for a command line interface, and it +keeps track of which buffer gets the focus. +""" +from __future__ import unicode_literals +from .enums import DEFAULT_BUFFER, SEARCH_BUFFER, SYSTEM_BUFFER, DUMMY_BUFFER +from .buffer import Buffer, AcceptAction +from .history import InMemoryHistory + +import six + +__all__ = ( + 'BufferMapping', +) + + +class BufferMapping(dict): + """ + Dictionary that maps the name of the buffers to the + :class:`~libs.prompt_toolkit.buffer.Buffer` instances. + + This mapping also keeps track of which buffer currently has the focus. + (Some methods receive a 'cli' parameter. This is useful for applications + where this `BufferMapping` is shared between several applications.) + """ + def __init__(self, buffers=None, initial=DEFAULT_BUFFER): + assert buffers is None or isinstance(buffers, dict) + + # Start with an empty dict. + super(BufferMapping, self).__init__() + + # Add default buffers. + self.update({ + # For the 'search' and 'system' buffers, 'returnable' is False, in + # order to block normal Enter/ControlC behaviour. + DEFAULT_BUFFER: Buffer(accept_action=AcceptAction.RETURN_DOCUMENT), + SEARCH_BUFFER: Buffer(history=InMemoryHistory(), accept_action=AcceptAction.IGNORE), + SYSTEM_BUFFER: Buffer(history=InMemoryHistory(), accept_action=AcceptAction.IGNORE), + DUMMY_BUFFER: Buffer(read_only=True), + }) + + # Add received buffers. + if buffers is not None: + self.update(buffers) + + # Focus stack. + self.focus_stack = [initial or DEFAULT_BUFFER] + + def current(self, cli): + """ + The active :class:`.Buffer`. + """ + return self[self.focus_stack[-1]] + + def current_name(self, cli): + """ + The name of the active :class:`.Buffer`. + """ + return self.focus_stack[-1] + + def previous(self, cli): + """ + Return the previously focussed :class:`.Buffer` or `None`. + """ + if len(self.focus_stack) > 1: + try: + return self[self.focus_stack[-2]] + except KeyError: + pass + + def focus(self, cli, buffer_name): + """ + Focus the buffer with the given name. + """ + assert isinstance(buffer_name, six.text_type) + self.focus_stack = [buffer_name] + + def push_focus(self, cli, buffer_name): + """ + Push buffer on the focus stack. + """ + assert isinstance(buffer_name, six.text_type) + self.focus_stack.append(buffer_name) + + def pop_focus(self, cli): + """ + Pop buffer from the focus stack. + """ + if len(self.focus_stack) > 1: + self.focus_stack.pop() + else: + raise IndexError('Cannot pop last item from the focus stack.') diff --git a/src/libs/prompt_toolkit/cache.py b/src/libs/prompt_toolkit/cache.py new file mode 100644 index 0000000..0648e97 --- /dev/null +++ b/src/libs/prompt_toolkit/cache.py @@ -0,0 +1,111 @@ +from __future__ import unicode_literals +from collections import deque +from functools import wraps + +__all__ = ( + 'SimpleCache', + 'FastDictCache', + 'memoized', +) + + +class SimpleCache(object): + """ + Very simple cache that discards the oldest item when the cache size is + exceeded. + + :param maxsize: Maximum size of the cache. (Don't make it too big.) + """ + def __init__(self, maxsize=8): + assert isinstance(maxsize, int) and maxsize > 0 + + self._data = {} + self._keys = deque() + self.maxsize = maxsize + + def get(self, key, getter_func): + """ + Get object from the cache. + If not found, call `getter_func` to resolve it, and put that on the top + of the cache instead. + """ + # Look in cache first. + try: + return self._data[key] + except KeyError: + # Not found? Get it. + value = getter_func() + self._data[key] = value + self._keys.append(key) + + # Remove the oldest key when the size is exceeded. + if len(self._data) > self.maxsize: + key_to_remove = self._keys.popleft() + if key_to_remove in self._data: + del self._data[key_to_remove] + + return value + + def clear(self): + " Clear cache. " + self._data = {} + self._keys = deque() + + +class FastDictCache(dict): + """ + Fast, lightweight cache which keeps at most `size` items. + It will discard the oldest items in the cache first. + + The cache is a dictionary, which doesn't keep track of access counts. + It is perfect to cache little immutable objects which are not expensive to + create, but where a dictionary lookup is still much faster than an object + instantiation. + + :param get_value: Callable that's called in case of a missing key. + """ + # NOTE: This cache is used to cache `libs.prompt_toolkit.layout.screen.Char` and + # `libs.prompt_toolkit.Document`. Make sure to keep this really lightweight. + # Accessing the cache should stay faster than instantiating new + # objects. + # (Dictionary lookups are really fast.) + # SimpleCache is still required for cases where the cache key is not + # the same as the arguments given to the function that creates the + # value.) + def __init__(self, get_value=None, size=1000000): + assert callable(get_value) + assert isinstance(size, int) and size > 0 + + self._keys = deque() + self.get_value = get_value + self.size = size + + def __missing__(self, key): + # Remove the oldest key when the size is exceeded. + if len(self) > self.size: + key_to_remove = self._keys.popleft() + if key_to_remove in self: + del self[key_to_remove] + + result = self.get_value(*key) + self[key] = result + self._keys.append(key) + return result + + +def memoized(maxsize=1024): + """ + Momoization decorator for immutable classes and pure functions. + """ + cache = SimpleCache(maxsize=maxsize) + + def decorator(obj): + @wraps(obj) + def new_callable(*a, **kw): + def create_new(): + return obj(*a, **kw) + + key = (a, tuple(kw.items())) + return cache.get(key, create_new) + return new_callable + return decorator diff --git a/src/libs/prompt_toolkit/clipboard/__init__.py b/src/libs/prompt_toolkit/clipboard/__init__.py new file mode 100644 index 0000000..56202dd --- /dev/null +++ b/src/libs/prompt_toolkit/clipboard/__init__.py @@ -0,0 +1,8 @@ +from .base import Clipboard, ClipboardData +from .in_memory import InMemoryClipboard + + +# We are not importing `PyperclipClipboard` here, because it would require the +# `pyperclip` module to be present. + +#from .pyperclip import PyperclipClipboard diff --git a/src/libs/prompt_toolkit/clipboard/base.py b/src/libs/prompt_toolkit/clipboard/base.py new file mode 100644 index 0000000..71290d7 --- /dev/null +++ b/src/libs/prompt_toolkit/clipboard/base.py @@ -0,0 +1,62 @@ +""" +Clipboard for command line interface. +""" +from __future__ import unicode_literals +from abc import ABCMeta, abstractmethod +from six import with_metaclass +import six + +from libs.prompt_toolkit.selection import SelectionType + +__all__ = ( + 'Clipboard', + 'ClipboardData', +) + + +class ClipboardData(object): + """ + Text on the clipboard. + + :param text: string + :param type: :class:`~prompt_toolkit.selection.SelectionType` + """ + def __init__(self, text='', type=SelectionType.CHARACTERS): + assert isinstance(text, six.string_types) + assert type in (SelectionType.CHARACTERS, SelectionType.LINES, SelectionType.BLOCK) + + self.text = text + self.type = type + + +class Clipboard(with_metaclass(ABCMeta, object)): + """ + Abstract baseclass for clipboards. + (An implementation can be in memory, it can share the X11 or Windows + keyboard, or can be persistent.) + """ + @abstractmethod + def set_data(self, data): + """ + Set data to the clipboard. + + :param data: :class:`~.ClipboardData` instance. + """ + + def set_text(self, text): # Not abstract. + """ + Shortcut for setting plain text on clipboard. + """ + assert isinstance(text, six.string_types) + self.set_data(ClipboardData(text)) + + def rotate(self): + """ + For Emacs mode, rotate the kill ring. + """ + + @abstractmethod + def get_data(self): + """ + Return clipboard data. + """ diff --git a/src/libs/prompt_toolkit/clipboard/in_memory.py b/src/libs/prompt_toolkit/clipboard/in_memory.py new file mode 100644 index 0000000..081666a --- /dev/null +++ b/src/libs/prompt_toolkit/clipboard/in_memory.py @@ -0,0 +1,42 @@ +from .base import Clipboard, ClipboardData + +from collections import deque + +__all__ = ( + 'InMemoryClipboard', +) + + +class InMemoryClipboard(Clipboard): + """ + Default clipboard implementation. + Just keep the data in memory. + + This implements a kill-ring, for Emacs mode. + """ + def __init__(self, data=None, max_size=60): + assert data is None or isinstance(data, ClipboardData) + assert max_size >= 1 + + self.max_size = max_size + self._ring = deque() + if data is not None: + self.set_data(data) + + def set_data(self, data): + assert isinstance(data, ClipboardData) + self._ring.appendleft(data) + + while len(self._ring) > self.max_size: + self._ring.pop() + + def get_data(self): + if self._ring: + return self._ring[0] + else: + return ClipboardData() + + def rotate(self): + if self._ring: + # Add the very first item at the end. + self._ring.append(self._ring.popleft()) diff --git a/src/libs/prompt_toolkit/clipboard/pyperclip.py b/src/libs/prompt_toolkit/clipboard/pyperclip.py new file mode 100644 index 0000000..f2b3b53 --- /dev/null +++ b/src/libs/prompt_toolkit/clipboard/pyperclip.py @@ -0,0 +1,39 @@ +from __future__ import absolute_import, unicode_literals +import pyperclip + +from libs.prompt_toolkit.selection import SelectionType +from .base import Clipboard, ClipboardData + +__all__ = ( + 'PyperclipClipboard', +) + + +class PyperclipClipboard(Clipboard): + """ + Clipboard that synchronizes with the Windows/Mac/Linux system clipboard, + using the pyperclip module. + """ + def __init__(self): + self._data = None + + def set_data(self, data): + assert isinstance(data, ClipboardData) + self._data = data + pyperclip.copy(data.text) + + def get_data(self): + text = pyperclip.paste() + + # When the clipboard data is equal to what we copied last time, reuse + # the `ClipboardData` instance. That way we're sure to keep the same + # `SelectionType`. + if self._data and self._data.text == text: + return self._data + + # Pyperclip returned something else. Create a new `ClipboardData` + # instance. + else: + return ClipboardData( + text=text, + type=SelectionType.LINES if '\n' in text else SelectionType.LINES) diff --git a/src/libs/prompt_toolkit/completion.py b/src/libs/prompt_toolkit/completion.py new file mode 100644 index 0000000..2d271dd --- /dev/null +++ b/src/libs/prompt_toolkit/completion.py @@ -0,0 +1,170 @@ +""" +""" +from __future__ import unicode_literals +from abc import ABCMeta, abstractmethod +from six import with_metaclass + +__all__ = ( + 'Completion', + 'Completer', + 'CompleteEvent', + 'get_common_complete_suffix', +) + + +class Completion(object): + """ + :param text: The new string that will be inserted into the document. + :param start_position: Position relative to the cursor_position where the + new text will start. The text will be inserted between the + start_position and the original cursor position. + :param display: (optional string) If the completion has to be displayed + differently in the completion menu. + :param display_meta: (Optional string) Meta information about the + completion, e.g. the path or source where it's coming from. + :param get_display_meta: Lazy `display_meta`. Retrieve meta information + only when meta is displayed. + """ + def __init__(self, text, start_position=0, display=None, display_meta=None, + get_display_meta=None): + self.text = text + self.start_position = start_position + self._display_meta = display_meta + self._get_display_meta = get_display_meta + + if display is None: + self.display = text + else: + self.display = display + + assert self.start_position <= 0 + + def __repr__(self): + if self.display == self.text: + return '%s(text=%r, start_position=%r)' % ( + self.__class__.__name__, self.text, self.start_position) + else: + return '%s(text=%r, start_position=%r, display=%r)' % ( + self.__class__.__name__, self.text, self.start_position, + self.display) + + def __eq__(self, other): + return ( + self.text == other.text and + self.start_position == other.start_position and + self.display == other.display and + self.display_meta == other.display_meta) + + def __hash__(self): + return hash((self.text, self.start_position, self.display, self.display_meta)) + + @property + def display_meta(self): + # Return meta-text. (This is lazy when using "get_display_meta".) + if self._display_meta is not None: + return self._display_meta + + elif self._get_display_meta: + self._display_meta = self._get_display_meta() + return self._display_meta + + else: + return '' + + def new_completion_from_position(self, position): + """ + (Only for internal use!) + Get a new completion by splitting this one. Used by + `CommandLineInterface` when it needs to have a list of new completions + after inserting the common prefix. + """ + assert isinstance(position, int) and position - self.start_position >= 0 + + return Completion( + text=self.text[position - self.start_position:], + display=self.display, + display_meta=self._display_meta, + get_display_meta=self._get_display_meta) + + +class CompleteEvent(object): + """ + Event that called the completer. + + :param text_inserted: When True, it means that completions are requested + because of a text insert. (`Buffer.complete_while_typing`.) + :param completion_requested: When True, it means that the user explicitely + pressed the `Tab` key in order to view the completions. + + These two flags can be used for instance to implemented a completer that + shows some completions when ``Tab`` has been pressed, but not + automatically when the user presses a space. (Because of + `complete_while_typing`.) + """ + def __init__(self, text_inserted=False, completion_requested=False): + assert not (text_inserted and completion_requested) + + #: Automatic completion while typing. + self.text_inserted = text_inserted + + #: Used explicitely requested completion by pressing 'tab'. + self.completion_requested = completion_requested + + def __repr__(self): + return '%s(text_inserted=%r, completion_requested=%r)' % ( + self.__class__.__name__, self.text_inserted, self.completion_requested) + + +class Completer(with_metaclass(ABCMeta, object)): + """ + Base class for completer implementations. + """ + @abstractmethod + def get_completions(self, document, complete_event): + """ + Yield :class:`.Completion` instances. + + :param document: :class:`~libs.prompt_toolkit.document.Document` instance. + :param complete_event: :class:`.CompleteEvent` instance. + """ + while False: + yield + + +def get_common_complete_suffix(document, completions): + """ + Return the common prefix for all completions. + """ + # Take only completions that don't change the text before the cursor. + def doesnt_change_before_cursor(completion): + end = completion.text[:-completion.start_position] + return document.text_before_cursor.endswith(end) + + completions2 = [c for c in completions if doesnt_change_before_cursor(c)] + + # When there is at least one completion that changes the text before the + # cursor, don't return any common part. + if len(completions2) != len(completions): + return '' + + # Return the common prefix. + def get_suffix(completion): + return completion.text[-completion.start_position:] + + return _commonprefix([get_suffix(c) for c in completions2]) + + +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/src/libs/prompt_toolkit/contrib/__init__.py b/src/libs/prompt_toolkit/contrib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/libs/prompt_toolkit/contrib/completers/__init__.py b/src/libs/prompt_toolkit/contrib/completers/__init__.py new file mode 100644 index 0000000..43893b8 --- /dev/null +++ b/src/libs/prompt_toolkit/contrib/completers/__init__.py @@ -0,0 +1,5 @@ +from __future__ import unicode_literals + +from .filesystem import PathCompleter +from .base import WordCompleter +from .system import SystemCompleter diff --git a/src/libs/prompt_toolkit/contrib/completers/base.py b/src/libs/prompt_toolkit/contrib/completers/base.py new file mode 100644 index 0000000..65a69fe --- /dev/null +++ b/src/libs/prompt_toolkit/contrib/completers/base.py @@ -0,0 +1,61 @@ +from __future__ import unicode_literals + +from six import string_types +from prompt_toolkit.completion import Completer, Completion + +__all__ = ( + 'WordCompleter', +) + + +class WordCompleter(Completer): + """ + Simple autocompletion on a list of words. + + :param words: List of words. + :param ignore_case: If True, case-insensitive completion. + :param meta_dict: Optional dict mapping words to their meta-information. + :param WORD: When True, use WORD characters. + :param sentence: When True, don't complete by comparing the word before the + cursor, but by comparing all the text before the cursor. In this case, + the list of words is just a list of strings, where each string can + contain spaces. (Can not be used together with the WORD option.) + :param match_middle: When True, match not only the start, but also in the + middle of the word. + """ + def __init__(self, words, ignore_case=False, meta_dict=None, WORD=False, + sentence=False, match_middle=False): + assert not (WORD and sentence) + + self.words = list(words) + self.ignore_case = ignore_case + self.meta_dict = meta_dict or {} + self.WORD = WORD + self.sentence = sentence + self.match_middle = match_middle + assert all(isinstance(w, string_types) for w in self.words) + + def get_completions(self, document, complete_event): + # Get word/text before cursor. + if self.sentence: + word_before_cursor = document.text_before_cursor + else: + word_before_cursor = document.get_word_before_cursor(WORD=self.WORD) + + if self.ignore_case: + word_before_cursor = word_before_cursor.lower() + + def word_matches(word): + """ True when the word before the cursor matches. """ + if self.ignore_case: + word = word.lower() + + if self.match_middle: + return word_before_cursor in word + else: + return word.startswith(word_before_cursor) + + for a in self.words: + if word_matches(a): + display_meta = self.meta_dict.get(a, '') + yield Completion(a, -len(word_before_cursor), display_meta=display_meta) diff --git a/src/libs/prompt_toolkit/contrib/completers/filesystem.py b/src/libs/prompt_toolkit/contrib/completers/filesystem.py new file mode 100644 index 0000000..cbd74d8 --- /dev/null +++ b/src/libs/prompt_toolkit/contrib/completers/filesystem.py @@ -0,0 +1,105 @@ +from __future__ import unicode_literals + +from prompt_toolkit.completion import Completer, Completion +import os + +__all__ = ( + 'PathCompleter', + 'ExecutableCompleter', +) + + +class PathCompleter(Completer): + """ + Complete for Path variables. + + :param get_paths: Callable which returns a list of directories to look into + when the user enters a relative path. + :param file_filter: Callable which takes a filename and returns whether + this file should show up in the completion. ``None`` + when no filtering has to be done. + :param min_input_len: Don't do autocompletion when the input string is shorter. + """ + def __init__(self, only_directories=False, get_paths=None, file_filter=None, + min_input_len=0, expanduser=False): + assert get_paths is None or callable(get_paths) + assert file_filter is None or callable(file_filter) + assert isinstance(min_input_len, int) + assert isinstance(expanduser, bool) + + self.only_directories = only_directories + self.get_paths = get_paths or (lambda: ['.']) + self.file_filter = file_filter or (lambda _: True) + self.min_input_len = min_input_len + self.expanduser = expanduser + + def get_completions(self, document, complete_event): + text = document.text_before_cursor + + # Complete only when we have at least the minimal input length, + # otherwise, we can too many results and autocompletion will become too + # heavy. + if len(text) < self.min_input_len: + return + + try: + # Do tilde expansion. + if self.expanduser: + text = os.path.expanduser(text) + + # Directories where to look. + dirname = os.path.dirname(text) + if dirname: + directories = [os.path.dirname(os.path.join(p, text)) + for p in self.get_paths()] + else: + directories = self.get_paths() + + # Start of current file. + prefix = os.path.basename(text) + + # Get all filenames. + filenames = [] + for directory in directories: + # Look for matches in this directory. + if os.path.isdir(directory): + for filename in os.listdir(directory): + if filename.startswith(prefix): + filenames.append((directory, filename)) + + # Sort + filenames = sorted(filenames, key=lambda k: k[1]) + + # Yield them. + for directory, filename in filenames: + completion = filename[len(prefix):] + full_name = os.path.join(directory, filename) + + if os.path.isdir(full_name): + # For directories, add a slash to the filename. + # (We don't add them to the `completion`. Users can type it + # to trigger the autocompletion themself.) + filename += '/' + elif self.only_directories: + continue + + if not self.file_filter(full_name): + continue + + yield Completion(completion, 0, display=filename) + except OSError: + pass + + +class ExecutableCompleter(PathCompleter): + """ + Complete only excutable files in the current path. + """ + def __init__(self): + PathCompleter.__init__( + self, + only_directories=False, + min_input_len=1, + get_paths=lambda: os.environ.get('PATH', '').split(os.pathsep), + file_filter=lambda name: os.access(name, os.X_OK), + expanduser=True), diff --git a/src/libs/prompt_toolkit/contrib/completers/system.py b/src/libs/prompt_toolkit/contrib/completers/system.py new file mode 100644 index 0000000..76d6c1f --- /dev/null +++ b/src/libs/prompt_toolkit/contrib/completers/system.py @@ -0,0 +1,56 @@ +from __future__ import unicode_literals + +from prompt_toolkit.contrib.regular_languages.completion import GrammarCompleter +from prompt_toolkit.contrib.regular_languages.compiler import compile + +from .filesystem import PathCompleter, ExecutableCompleter + +__all__ = ( + 'SystemCompleter', +) + + +class SystemCompleter(GrammarCompleter): + """ + Completer for system commands. + """ + def __init__(self): + # Compile grammar. + g = compile( + r""" + # First we have an executable. + (?P[^\s]+) + + # Ignore literals in between. + ( + \s+ + ("[^"]*" | '[^']*' | [^'"]+ ) + )* + + \s+ + + # Filename as parameters. + ( + (?P[^\s]+) | + "(?P[^\s]+)" | + '(?P[^\s]+)' + ) + """, + escape_funcs={ + 'double_quoted_filename': (lambda string: string.replace('"', '\\"')), + 'single_quoted_filename': (lambda string: string.replace("'", "\\'")), + }, + unescape_funcs={ + 'double_quoted_filename': (lambda string: string.replace('\\"', '"')), # XXX: not enterily correct. + 'single_quoted_filename': (lambda string: string.replace("\\'", "'")), + }) + + # Create GrammarCompleter + super(SystemCompleter, self).__init__( + g, + { + 'executable': ExecutableCompleter(), + 'filename': PathCompleter(only_directories=False, expanduser=True), + 'double_quoted_filename': PathCompleter(only_directories=False, expanduser=True), + 'single_quoted_filename': PathCompleter(only_directories=False, expanduser=True), + }) diff --git a/src/libs/prompt_toolkit/contrib/regular_languages/__init__.py b/src/libs/prompt_toolkit/contrib/regular_languages/__init__.py new file mode 100644 index 0000000..314cb1f --- /dev/null +++ b/src/libs/prompt_toolkit/contrib/regular_languages/__init__.py @@ -0,0 +1,76 @@ +r""" +Tool for expressing the grammar of an input as a regular language. +================================================================== + +The grammar for the input of many simple command line interfaces can be +expressed by a regular language. Examples are PDB (the Python debugger); a +simple (bash-like) shell with "pwd", "cd", "cat" and "ls" commands; arguments +that you can pass to an executable; etc. It is possible to use regular +expressions for validation and parsing of such a grammar. (More about regular +languages: http://en.wikipedia.org/wiki/Regular_language) + +Example +------- + +Let's take the pwd/cd/cat/ls example. We want to have a shell that accepts +these three commands. "cd" is followed by a quoted directory name and "cat" is +followed by a quoted file name. (We allow quotes inside the filename when +they're escaped with a backslash.) We could define the grammar using the +following regular expression:: + + grammar = \s* ( + pwd | + ls | + (cd \s+ " ([^"]|\.)+ ") | + (cat \s+ " ([^"]|\.)+ ") + ) \s* + + +What can we do with this grammar? +--------------------------------- + +- Syntax highlighting: We could use this for instance to give file names + different colour. +- Parse the result: .. We can extract the file names and commands by using a + regular expression with named groups. +- Input validation: .. Don't accept anything that does not match this grammar. + When combined with a parser, we can also recursively do + filename validation (and accept only existing files.) +- Autocompletion: .... Each part of the grammar can have its own autocompleter. + "cat" has to be completed using file names, while "cd" + has to be completed using directory names. + +How does it work? +----------------- + +As a user of this library, you have to define the grammar of the input as a +regular expression. The parts of this grammar where autocompletion, validation +or any other processing is required need to be marked using a regex named +group. Like ``(?P...)`` for instance. + +When the input is processed for validation (for instance), the regex will +execute, the named group is captured, and the validator associated with this +named group will test the captured string. + +There is one tricky bit: + + Ofter we operate on incomplete input (this is by definition the case for + autocompletion) and we have to decide for the cursor position in which + possible state the grammar it could be and in which way variables could be + matched up to that point. + +To solve this problem, the compiler takes the original regular expression and +translates it into a set of other regular expressions which each match prefixes +of strings that would match the first expression. (We translate it into +multiple expression, because we want to have each possible state the regex +could be in -- in case there are several or-clauses with each different +completers.) + + +TODO: some examples of: + - How to create a highlighter from this grammar. + - How to create a validator from this grammar. + - How to create an autocompleter from this grammar. + - How to create a parser from this grammar. +""" +from .compiler import compile diff --git a/src/libs/prompt_toolkit/contrib/regular_languages/compiler.py b/src/libs/prompt_toolkit/contrib/regular_languages/compiler.py new file mode 100644 index 0000000..01476bf --- /dev/null +++ b/src/libs/prompt_toolkit/contrib/regular_languages/compiler.py @@ -0,0 +1,408 @@ +r""" +Compiler for a regular grammar. + +Example usage:: + + # Create and compile grammar. + p = compile('add \s+ (?P[^\s]+) \s+ (?P[^\s]+)') + + # Match input string. + m = p.match('add 23 432') + + # Get variables. + m.variables().get('var1') # Returns "23" + m.variables().get('var2') # Returns "432" + + +Partial matches are possible:: + + # Create and compile grammar. + p = compile(''' + # Operators with two arguments. + ((?P[^\s]+) \s+ (?P[^\s]+) \s+ (?P[^\s]+)) | + + # Operators with only one arguments. + ((?P[^\s]+) \s+ (?P[^\s]+)) + ''') + + # Match partial input string. + m = p.match_prefix('add 23') + + # Get variables. (Notice that both operator1 and operator2 contain the + # value "add".) This is because our input is incomplete, and we don't know + # yet in which rule of the regex we we'll end up. It could also be that + # `operator1` and `operator2` have a different autocompleter and we want to + # call all possible autocompleters that would result in valid input.) + m.variables().get('var1') # Returns "23" + m.variables().get('operator1') # Returns "add" + m.variables().get('operator2') # Returns "add" + +""" +from __future__ import unicode_literals +import re + +from six.moves import range +from .regex_parser import Any, Sequence, Regex, Variable, Repeat, Lookahead +from .regex_parser import parse_regex, tokenize_regex + +__all__ = ( + 'compile', +) + + +# Name of the named group in the regex, matching trailing input. +# (Trailing input is when the input contains characters after the end of the +# expression has been matched.) +_INVALID_TRAILING_INPUT = 'invalid_trailing' + + +class _CompiledGrammar(object): + """ + Compiles a grammar. This will take the parse tree of a regular expression + and compile the grammar. + + :param root_node: :class~`.regex_parser.Node` instance. + :param escape_funcs: `dict` mapping variable names to escape callables. + :param unescape_funcs: `dict` mapping variable names to unescape callables. + """ + def __init__(self, root_node, escape_funcs=None, unescape_funcs=None): + self.root_node = root_node + self.escape_funcs = escape_funcs or {} + self.unescape_funcs = unescape_funcs or {} + + #: Dictionary that will map the redex names to Node instances. + self._group_names_to_nodes = {} # Maps regex group names to varnames. + counter = [0] + + def create_group_func(node): + name = 'n%s' % counter[0] + self._group_names_to_nodes[name] = node.varname + counter[0] += 1 + return name + + # Compile regex strings. + self._re_pattern = '^%s$' % self._transform(root_node, create_group_func) + self._re_prefix_patterns = list(self._transform_prefix(root_node, create_group_func)) + + # Compile the regex itself. + flags = re.DOTALL # Note that we don't need re.MULTILINE! (^ and $ + # still represent the start and end of input text.) + self._re = re.compile(self._re_pattern, flags) + self._re_prefix = [re.compile(t, flags) for t in self._re_prefix_patterns] + + # We compile one more set of regexes, similar to `_re_prefix`, but accept any trailing + # input. This will ensure that we can still highlight the input correctly, even when the + # input contains some additional characters at the end that don't match the grammar.) + self._re_prefix_with_trailing_input = [ + re.compile(r'(?:%s)(?P<%s>.*?)$' % (t.rstrip('$'), _INVALID_TRAILING_INPUT), flags) + for t in self._re_prefix_patterns] + + def escape(self, varname, value): + """ + Escape `value` to fit in the place of this variable into the grammar. + """ + f = self.escape_funcs.get(varname) + return f(value) if f else value + + def unescape(self, varname, value): + """ + Unescape `value`. + """ + f = self.unescape_funcs.get(varname) + return f(value) if f else value + + @classmethod + def _transform(cls, root_node, create_group_func): + """ + Turn a :class:`Node` object into a regular expression. + + :param root_node: The :class:`Node` instance for which we generate the grammar. + :param create_group_func: A callable which takes a `Node` and returns the next + free name for this node. + """ + def transform(node): + # Turn `Any` into an OR. + if isinstance(node, Any): + return '(?:%s)' % '|'.join(transform(c) for c in node.children) + + # Concatenate a `Sequence` + elif isinstance(node, Sequence): + return ''.join(transform(c) for c in node.children) + + # For Regex and Lookahead nodes, just insert them literally. + elif isinstance(node, Regex): + return node.regex + + elif isinstance(node, Lookahead): + before = ('(?!' if node.negative else '(=') + return before + transform(node.childnode) + ')' + + # A `Variable` wraps the children into a named group. + elif isinstance(node, Variable): + return '(?P<%s>%s)' % (create_group_func(node), transform(node.childnode)) + + # `Repeat`. + elif isinstance(node, Repeat): + return '(?:%s){%i,%s}%s' % ( + transform(node.childnode), node.min_repeat, + ('' if node.max_repeat is None else str(node.max_repeat)), + ('' if node.greedy else '?') + ) + else: + raise TypeError('Got %r' % (node, )) + + return transform(root_node) + + @classmethod + def _transform_prefix(cls, root_node, create_group_func): + """ + Yield all the regular expressions matching a prefix of the grammar + defined by the `Node` instance. + + This can yield multiple expressions, because in the case of on OR + operation in the grammar, we can have another outcome depending on + which clause would appear first. E.g. "(A|B)C" is not the same as + "(B|A)C" because the regex engine is lazy and takes the first match. + However, because we the current input is actually a prefix of the + grammar which meight not yet contain the data for "C", we need to know + both intermediate states, in order to call the appropriate + autocompletion for both cases. + + :param root_node: The :class:`Node` instance for which we generate the grammar. + :param create_group_func: A callable which takes a `Node` and returns the next + free name for this node. + """ + def transform(node): + # Generate regexes for all permutations of this OR. Each node + # should be in front once. + if isinstance(node, Any): + for c in node.children: + for r in transform(c): + yield '(?:%s)?' % r + + # For a sequence. We can either have a match for the sequence + # of all the children, or for an exact match of the first X + # children, followed by a partial match of the next children. + elif isinstance(node, Sequence): + for i in range(len(node.children)): + a = [cls._transform(c, create_group_func) for c in node.children[:i]] + for c in transform(node.children[i]): + yield '(?:%s)' % (''.join(a) + c) + + elif isinstance(node, Regex): + yield '(?:%s)?' % node.regex + + elif isinstance(node, Lookahead): + if node.negative: + yield '(?!%s)' % cls._transform(node.childnode, create_group_func) + else: + # Not sure what the correct semantics are in this case. + # (Probably it's not worth implementing this.) + raise Exception('Positive lookahead not yet supported.') + + elif isinstance(node, Variable): + # (Note that we should not append a '?' here. the 'transform' + # method will already recursively do that.) + for c in transform(node.childnode): + yield '(?P<%s>%s)' % (create_group_func(node), c) + + elif isinstance(node, Repeat): + # If we have a repetition of 8 times. That would mean that the + # current input could have for instance 7 times a complete + # match, followed by a partial match. + prefix = cls._transform(node.childnode, create_group_func) + + for c in transform(node.childnode): + if node.max_repeat: + repeat_sign = '{,%i}' % (node.max_repeat - 1) + else: + repeat_sign = '*' + yield '(?:%s)%s%s(?:%s)?' % ( + prefix, + repeat_sign, + ('' if node.greedy else '?'), + c) + + else: + raise TypeError('Got %r' % node) + + for r in transform(root_node): + yield '^%s$' % r + + def match(self, string): + """ + Match the string with the grammar. + Returns a :class:`Match` instance or `None` when the input doesn't match the grammar. + + :param string: The input string. + """ + m = self._re.match(string) + + if m: + return Match(string, [(self._re, m)], self._group_names_to_nodes, self.unescape_funcs) + + def match_prefix(self, string): + """ + Do a partial match of the string with the grammar. The returned + :class:`Match` instance can contain multiple representations of the + match. This will never return `None`. If it doesn't match at all, the "trailing input" + part will capture all of the input. + + :param string: The input string. + """ + # First try to match using `_re_prefix`. If nothing is found, use the patterns that + # also accept trailing characters. + for patterns in [self._re_prefix, self._re_prefix_with_trailing_input]: + matches = [(r, r.match(string)) for r in patterns] + matches = [(r, m) for r, m in matches if m] + + if matches != []: + return Match(string, matches, self._group_names_to_nodes, self.unescape_funcs) + + +class Match(object): + """ + :param string: The input string. + :param re_matches: List of (compiled_re_pattern, re_match) tuples. + :param group_names_to_nodes: Dictionary mapping all the re group names to the matching Node instances. + """ + def __init__(self, string, re_matches, group_names_to_nodes, unescape_funcs): + self.string = string + self._re_matches = re_matches + self._group_names_to_nodes = group_names_to_nodes + self._unescape_funcs = unescape_funcs + + def _nodes_to_regs(self): + """ + Return a list of (varname, reg) tuples. + """ + def get_tuples(): + for r, re_match in self._re_matches: + for group_name, group_index in r.groupindex.items(): + if group_name != _INVALID_TRAILING_INPUT: + reg = re_match.regs[group_index] + node = self._group_names_to_nodes[group_name] + yield (node, reg) + + return list(get_tuples()) + + def _nodes_to_values(self): + """ + Returns list of list of (Node, string_value) tuples. + """ + def is_none(slice): + return slice[0] == -1 and slice[1] == -1 + + def get(slice): + return self.string[slice[0]:slice[1]] + + return [(varname, get(slice), slice) for varname, slice in self._nodes_to_regs() if not is_none(slice)] + + def _unescape(self, varname, value): + unwrapper = self._unescape_funcs.get(varname) + return unwrapper(value) if unwrapper else value + + def variables(self): + """ + Returns :class:`Variables` instance. + """ + return Variables([(k, self._unescape(k, v), sl) for k, v, sl in self._nodes_to_values()]) + + def trailing_input(self): + """ + Get the `MatchVariable` instance, representing trailing input, if there is any. + "Trailing input" is input at the end that does not match the grammar anymore, but + when this is removed from the end of the input, the input would be a valid string. + """ + slices = [] + + # Find all regex group for the name _INVALID_TRAILING_INPUT. + for r, re_match in self._re_matches: + for group_name, group_index in r.groupindex.items(): + if group_name == _INVALID_TRAILING_INPUT: + slices.append(re_match.regs[group_index]) + + # Take the smallest part. (Smaller trailing text means that a larger input has + # been matched, so that is better.) + if slices: + slice = [max(i[0] for i in slices), max(i[1] for i in slices)] + value = self.string[slice[0]:slice[1]] + return MatchVariable('', value, slice) + + def end_nodes(self): + """ + Yields `MatchVariable` instances for all the nodes having their end + position at the end of the input string. + """ + for varname, reg in self._nodes_to_regs(): + # If this part goes until the end of the input string. + if reg[1] == len(self.string): + value = self._unescape(varname, self.string[reg[0]: reg[1]]) + yield MatchVariable(varname, value, (reg[0], reg[1])) + + +class Variables(object): + def __init__(self, tuples): + #: List of (varname, value, slice) tuples. + self._tuples = tuples + + def __repr__(self): + return '%s(%s)' % ( + self.__class__.__name__, ', '.join('%s=%r' % (k, v) for k, v, _ in self._tuples)) + + def get(self, key, default=None): + items = self.getall(key) + return items[0] if items else default + + def getall(self, key): + return [v for k, v, _ in self._tuples if k == key] + + def __getitem__(self, key): + return self.get(key) + + def __iter__(self): + """ + Yield `MatchVariable` instances. + """ + for varname, value, slice in self._tuples: + yield MatchVariable(varname, value, slice) + + +class MatchVariable(object): + """ + Represents a match of a variable in the grammar. + + :param varname: (string) Name of the variable. + :param value: (string) Value of this variable. + :param slice: (start, stop) tuple, indicating the position of this variable + in the input string. + """ + def __init__(self, varname, value, slice): + self.varname = varname + self.value = value + self.slice = slice + + self.start = self.slice[0] + self.stop = self.slice[1] + + def __repr__(self): + return '%s(%r, %r)' % (self.__class__.__name__, self.varname, self.value) + + +def compile(expression, escape_funcs=None, unescape_funcs=None): + """ + Compile grammar (given as regex string), returning a `CompiledGrammar` + instance. + """ + return _compile_from_parse_tree( + parse_regex(tokenize_regex(expression)), + escape_funcs=escape_funcs, + unescape_funcs=unescape_funcs) + + +def _compile_from_parse_tree(root_node, *a, **kw): + """ + Compile grammar (given as parse tree), returning a `CompiledGrammar` + instance. + """ + return _CompiledGrammar(root_node, *a, **kw) diff --git a/src/libs/prompt_toolkit/contrib/regular_languages/completion.py b/src/libs/prompt_toolkit/contrib/regular_languages/completion.py new file mode 100644 index 0000000..bb49986 --- /dev/null +++ b/src/libs/prompt_toolkit/contrib/regular_languages/completion.py @@ -0,0 +1,84 @@ +""" +Completer for a regular grammar. +""" +from __future__ import unicode_literals + +from prompt_toolkit.completion import Completer, Completion +from prompt_toolkit.document import Document + +from .compiler import _CompiledGrammar + +__all__ = ( + 'GrammarCompleter', +) + + +class GrammarCompleter(Completer): + """ + Completer which can be used for autocompletion according to variables in + the grammar. Each variable can have a different autocompleter. + + :param compiled_grammar: `GrammarCompleter` instance. + :param completers: `dict` mapping variable names of the grammar to the + `Completer` instances to be used for each variable. + """ + def __init__(self, compiled_grammar, completers): + assert isinstance(compiled_grammar, _CompiledGrammar) + assert isinstance(completers, dict) + + self.compiled_grammar = compiled_grammar + self.completers = completers + + def get_completions(self, document, complete_event): + m = self.compiled_grammar.match_prefix(document.text_before_cursor) + + if m: + completions = self._remove_duplicates( + self._get_completions_for_match(m, complete_event)) + + for c in completions: + yield c + + def _get_completions_for_match(self, match, complete_event): + """ + Yield all the possible completions for this input string. + (The completer assumes that the cursor position was at the end of the + input string.) + """ + for match_variable in match.end_nodes(): + varname = match_variable.varname + start = match_variable.start + + completer = self.completers.get(varname) + + if completer: + text = match_variable.value + + # Unwrap text. + unwrapped_text = self.compiled_grammar.unescape(varname, text) + + # Create a document, for the completions API (text/cursor_position) + document = Document(unwrapped_text, len(unwrapped_text)) + + # Call completer + for completion in completer.get_completions(document, complete_event): + new_text = unwrapped_text[:len(text) + completion.start_position] + completion.text + + # Wrap again. + yield Completion( + text=self.compiled_grammar.escape(varname, new_text), + start_position=start - len(match.string), + display=completion.display, + display_meta=completion.display_meta) + + def _remove_duplicates(self, items): + """ + Remove duplicates, while keeping the order. + (Sometimes we have duplicates, because the there several matches of the + same grammar, each yielding similar completions.) + """ + result = [] + for i in items: + if i not in result: + result.append(i) + return result diff --git a/src/libs/prompt_toolkit/contrib/regular_languages/lexer.py b/src/libs/prompt_toolkit/contrib/regular_languages/lexer.py new file mode 100644 index 0000000..c166d84 --- /dev/null +++ b/src/libs/prompt_toolkit/contrib/regular_languages/lexer.py @@ -0,0 +1,90 @@ +""" +`GrammarLexer` is compatible with Pygments lexers and can be used to highlight +the input using a regular grammar with token annotations. +""" +from __future__ import unicode_literals +from prompt_toolkit.document import Document +from prompt_toolkit.layout.lexers import Lexer +from prompt_toolkit.layout.utils import split_lines +from prompt_toolkit.token import Token + +from .compiler import _CompiledGrammar +from six.moves import range + +__all__ = ( + 'GrammarLexer', +) + + +class GrammarLexer(Lexer): + """ + Lexer which can be used for highlighting of tokens according to variables in the grammar. + + (It does not actual lexing of the string, but it exposes an API, compatible + with the Pygments lexer class.) + + :param compiled_grammar: Grammar as returned by the `compile()` function. + :param lexers: Dictionary mapping variable names of the regular grammar to + the lexers that should be used for this part. (This can + call other lexers recursively.) If you wish a part of the + grammar to just get one token, use a + `prompt_toolkit.layout.lexers.SimpleLexer`. + """ + def __init__(self, compiled_grammar, default_token=None, lexers=None): + assert isinstance(compiled_grammar, _CompiledGrammar) + assert default_token is None or isinstance(default_token, tuple) + assert lexers is None or all(isinstance(v, Lexer) for k, v in lexers.items()) + assert lexers is None or isinstance(lexers, dict) + + self.compiled_grammar = compiled_grammar + self.default_token = default_token or Token + self.lexers = lexers or {} + + def _get_tokens(self, cli, text): + m = self.compiled_grammar.match_prefix(text) + + if m: + characters = [[self.default_token, c] for c in text] + + for v in m.variables(): + # If we have a `Lexer` instance for this part of the input. + # Tokenize recursively and apply tokens. + lexer = self.lexers.get(v.varname) + + if lexer: + document = Document(text[v.start:v.stop]) + lexer_tokens_for_line = lexer.lex_document(cli, document) + lexer_tokens = [] + for i in range(len(document.lines)): + lexer_tokens.extend(lexer_tokens_for_line(i)) + lexer_tokens.append((Token, '\n')) + if lexer_tokens: + lexer_tokens.pop() + + i = v.start + for t, s in lexer_tokens: + for c in s: + if characters[i][0] == self.default_token: + characters[i][0] = t + i += 1 + + # Highlight trailing input. + trailing_input = m.trailing_input() + if trailing_input: + for i in range(trailing_input.start, trailing_input.stop): + characters[i][0] = Token.TrailingInput + + return characters + else: + return [(Token, text)] + + def lex_document(self, cli, document): + lines = list(split_lines(self._get_tokens(cli, document.text))) + + def get_line(lineno): + try: + return lines[lineno] + except IndexError: + return [] + + return get_line diff --git a/src/libs/prompt_toolkit/contrib/regular_languages/regex_parser.py b/src/libs/prompt_toolkit/contrib/regular_languages/regex_parser.py new file mode 100644 index 0000000..e5909b2 --- /dev/null +++ b/src/libs/prompt_toolkit/contrib/regular_languages/regex_parser.py @@ -0,0 +1,262 @@ +""" +Parser for parsing a regular expression. +Take a string representing a regular expression and return the root node of its +parse tree. + +usage:: + + root_node = parse_regex('(hello|world)') + +Remarks: +- The regex parser processes multiline, it ignores all whitespace and supports + multiple named groups with the same name and #-style comments. + +Limitations: +- Lookahead is not supported. +""" +from __future__ import unicode_literals +import re + +__all__ = ( + 'Repeat', + 'Variable', + 'Regex', + 'Lookahead', + + 'tokenize_regex', + 'parse_regex', +) + + +class Node(object): + """ + Base class for all the grammar nodes. + (You don't initialize this one.) + """ + def __add__(self, other_node): + return Sequence([self, other_node]) + + def __or__(self, other_node): + return Any([self, other_node]) + + +class Any(Node): + """ + Union operation (OR operation) between several grammars. You don't + initialize this yourself, but it's a result of a "Grammar1 | Grammar2" + operation. + """ + def __init__(self, children): + self.children = children + + def __or__(self, other_node): + return Any(self.children + [other_node]) + + def __repr__(self): + return '%s(%r)' % (self.__class__.__name__, self.children) + + +class Sequence(Node): + """ + Concatenation operation of several grammars. You don't initialize this + yourself, but it's a result of a "Grammar1 + Grammar2" operation. + """ + def __init__(self, children): + self.children = children + + def __add__(self, other_node): + return Sequence(self.children + [other_node]) + + def __repr__(self): + return '%s(%r)' % (self.__class__.__name__, self.children) + + +class Regex(Node): + """ + Regular expression. + """ + def __init__(self, regex): + re.compile(regex) # Validate + + self.regex = regex + + def __repr__(self): + return '%s(/%s/)' % (self.__class__.__name__, self.regex) + + +class Lookahead(Node): + """ + Lookahead expression. + """ + def __init__(self, childnode, negative=False): + self.childnode = childnode + self.negative = negative + + def __repr__(self): + return '%s(%r)' % (self.__class__.__name__, self.childnode) + + +class Variable(Node): + """ + Mark a variable in the regular grammar. This will be translated into a + named group. Each variable can have his own completer, validator, etc.. + + :param childnode: The grammar which is wrapped inside this variable. + :param varname: String. + """ + def __init__(self, childnode, varname=None): + self.childnode = childnode + self.varname = varname + + def __repr__(self): + return '%s(childnode=%r, varname=%r)' % ( + self.__class__.__name__, self.childnode, self.varname) + + +class Repeat(Node): + def __init__(self, childnode, min_repeat=0, max_repeat=None, greedy=True): + self.childnode = childnode + self.min_repeat = min_repeat + self.max_repeat = max_repeat + self.greedy = greedy + + def __repr__(self): + return '%s(childnode=%r)' % (self.__class__.__name__, self.childnode) + + +def tokenize_regex(input): + """ + Takes a string, representing a regular expression as input, and tokenizes + it. + + :param input: string, representing a regular expression. + :returns: List of tokens. + """ + # Regular expression for tokenizing other regular expressions. + p = re.compile(r'''^( + \(\?P\<[a-zA-Z0-9_-]+\> | # Start of named group. + \(\?#[^)]*\) | # Comment + \(\?= | # Start of lookahead assertion + \(\?! | # Start of negative lookahead assertion + \(\?<= | # If preceded by. + \(\?< | # If not preceded by. + \(?: | # Start of group. (non capturing.) + \( | # Start of group. + \(?[iLmsux] | # Flags. + \(?P=[a-zA-Z]+\) | # Back reference to named group + \) | # End of group. + \{[^{}]*\} | # Repetition + \*\? | \+\? | \?\?\ | # Non greedy repetition. + \* | \+ | \? | # Repetition + \#.*\n | # Comment + \\. | + + # Character group. + \[ + ( [^\]\\] | \\.)* + \] | + + [^(){}] | + . + )''', re.VERBOSE) + + tokens = [] + + while input: + m = p.match(input) + if m: + token, input = input[:m.end()], input[m.end():] + if not token.isspace(): + tokens.append(token) + else: + raise Exception('Could not tokenize input regex.') + + return tokens + + +def parse_regex(regex_tokens): + """ + Takes a list of tokens from the tokenizer, and returns a parse tree. + """ + # We add a closing brace because that represents the final pop of the stack. + tokens = [')'] + regex_tokens[::-1] + + def wrap(lst): + """ Turn list into sequence when it contains several items. """ + if len(lst) == 1: + return lst[0] + else: + return Sequence(lst) + + def _parse(): + or_list = [] + result = [] + + def wrapped_result(): + if or_list == []: + return wrap(result) + else: + or_list.append(result) + return Any([wrap(i) for i in or_list]) + + while tokens: + t = tokens.pop() + + if t.startswith('(?P<'): + variable = Variable(_parse(), varname=t[4:-1]) + result.append(variable) + + elif t in ('*', '*?'): + greedy = (t == '*') + result[-1] = Repeat(result[-1], greedy=greedy) + + elif t in ('+', '+?'): + greedy = (t == '+') + result[-1] = Repeat(result[-1], min_repeat=1, greedy=greedy) + + elif t in ('?', '??'): + if result == []: + raise Exception('Nothing to repeat.' + repr(tokens)) + else: + greedy = (t == '?') + result[-1] = Repeat(result[-1], min_repeat=0, max_repeat=1, greedy=greedy) + + elif t == '|': + or_list.append(result) + result = [] + + elif t in ('(', '(?:'): + result.append(_parse()) + + elif t == '(?!': + result.append(Lookahead(_parse(), negative=True)) + + elif t == '(?=': + result.append(Lookahead(_parse(), negative=False)) + + elif t == ')': + return wrapped_result() + + elif t.startswith('#'): + pass + + elif t.startswith('{'): + # TODO: implement! + raise Exception('{}-style repitition not yet supported' % t) + + elif t.startswith('(?'): + raise Exception('%r not supported' % t) + + elif t.isspace(): + pass + else: + result.append(Regex(t)) + + raise Exception("Expecting ')' token") + + result = _parse() + + if len(tokens) != 0: + raise Exception("Unmatched parantheses.") + else: + return result diff --git a/src/libs/prompt_toolkit/contrib/regular_languages/validation.py b/src/libs/prompt_toolkit/contrib/regular_languages/validation.py new file mode 100644 index 0000000..d5f8cfc --- /dev/null +++ b/src/libs/prompt_toolkit/contrib/regular_languages/validation.py @@ -0,0 +1,57 @@ +""" +Validator for a regular langage. +""" +from __future__ import unicode_literals + +from prompt_toolkit.validation import Validator, ValidationError +from prompt_toolkit.document import Document + +from .compiler import _CompiledGrammar + +__all__ = ( + 'GrammarValidator', +) + + +class GrammarValidator(Validator): + """ + Validator which can be used for validation according to variables in + the grammar. Each variable can have its own validator. + + :param compiled_grammar: `GrammarCompleter` instance. + :param validators: `dict` mapping variable names of the grammar to the + `Validator` instances to be used for each variable. + """ + def __init__(self, compiled_grammar, validators): + assert isinstance(compiled_grammar, _CompiledGrammar) + assert isinstance(validators, dict) + + self.compiled_grammar = compiled_grammar + self.validators = validators + + def validate(self, document): + # Parse input document. + # We use `match`, not `match_prefix`, because for validation, we want + # the actual, unambiguous interpretation of the input. + m = self.compiled_grammar.match(document.text) + + if m: + for v in m.variables(): + validator = self.validators.get(v.varname) + + if validator: + # Unescape text. + unwrapped_text = self.compiled_grammar.unescape(v.varname, v.value) + + # Create a document, for the completions API (text/cursor_position) + inner_document = Document(unwrapped_text, len(unwrapped_text)) + + try: + validator.validate(inner_document) + except ValidationError as e: + raise ValidationError( + cursor_position=v.start + e.cursor_position, + message=e.message) + else: + raise ValidationError(cursor_position=len(document.text), + message='Invalid command') diff --git a/src/libs/prompt_toolkit/contrib/telnet/__init__.py b/src/libs/prompt_toolkit/contrib/telnet/__init__.py new file mode 100644 index 0000000..7b7aeec --- /dev/null +++ b/src/libs/prompt_toolkit/contrib/telnet/__init__.py @@ -0,0 +1,2 @@ +from .server import * +from .application import * diff --git a/src/libs/prompt_toolkit/contrib/telnet/application.py b/src/libs/prompt_toolkit/contrib/telnet/application.py new file mode 100644 index 0000000..7fe6cc9 --- /dev/null +++ b/src/libs/prompt_toolkit/contrib/telnet/application.py @@ -0,0 +1,32 @@ +""" +Interface for Telnet applications. +""" +from __future__ import unicode_literals +from abc import ABCMeta, abstractmethod +from six import with_metaclass + +__all__ = ( + 'TelnetApplication', +) + + +class TelnetApplication(with_metaclass(ABCMeta, object)): + """ + The interface which has to be implemented for any telnet application. + An instance of this class has to be passed to `TelnetServer`. + """ + @abstractmethod + def client_connected(self, telnet_connection): + """ + Called when a new client was connected. + + Probably you want to call `telnet_connection.set_cli` here to set a + the CommandLineInterface instance to be used. + Hint: Use the following shortcut: `prompt_toolkit.shortcuts.create_cli` + """ + + @abstractmethod + def client_leaving(self, telnet_connection): + """ + Called when a client quits. + """ diff --git a/src/libs/prompt_toolkit/contrib/telnet/log.py b/src/libs/prompt_toolkit/contrib/telnet/log.py new file mode 100644 index 0000000..10792ce --- /dev/null +++ b/src/libs/prompt_toolkit/contrib/telnet/log.py @@ -0,0 +1,11 @@ +""" +Python logger for the telnet server. +""" +from __future__ import unicode_literals +import logging + +logger = logging.getLogger(__package__) + +__all__ = ( + 'logger', +) diff --git a/src/libs/prompt_toolkit/contrib/telnet/protocol.py b/src/libs/prompt_toolkit/contrib/telnet/protocol.py new file mode 100644 index 0000000..b1bb0cc --- /dev/null +++ b/src/libs/prompt_toolkit/contrib/telnet/protocol.py @@ -0,0 +1,181 @@ +""" +Parser for the Telnet protocol. (Not a complete implementation of the telnet +specification, but sufficient for a command line interface.) + +Inspired by `Twisted.conch.telnet`. +""" +from __future__ import unicode_literals + +import struct +from six import int2byte, binary_type, iterbytes + +from .log import logger + +__all__ = ( + 'TelnetProtocolParser', +) + +# Telnet constants. +NOP = int2byte(0) +SGA = int2byte(3) + +IAC = int2byte(255) +DO = int2byte(253) +DONT = int2byte(254) +LINEMODE = int2byte(34) +SB = int2byte(250) +WILL = int2byte(251) +WONT = int2byte(252) +MODE = int2byte(1) +SE = int2byte(240) +ECHO = int2byte(1) +NAWS = int2byte(31) +LINEMODE = int2byte(34) +SUPPRESS_GO_AHEAD = int2byte(3) + +DM = int2byte(242) +BRK = int2byte(243) +IP = int2byte(244) +AO = int2byte(245) +AYT = int2byte(246) +EC = int2byte(247) +EL = int2byte(248) +GA = int2byte(249) + + +class TelnetProtocolParser(object): + """ + Parser for the Telnet protocol. + Usage:: + + def data_received(data): + print(data) + + def size_received(rows, columns): + print(rows, columns) + + p = TelnetProtocolParser(data_received, size_received) + p.feed(binary_data) + """ + def __init__(self, data_received_callback, size_received_callback): + self.data_received_callback = data_received_callback + self.size_received_callback = size_received_callback + + self._parser = self._parse_coroutine() + self._parser.send(None) + + def received_data(self, data): + self.data_received_callback(data) + + def do_received(self, data): + """ Received telnet DO command. """ + logger.info('DO %r', data) + + def dont_received(self, data): + """ Received telnet DONT command. """ + logger.info('DONT %r', data) + + def will_received(self, data): + """ Received telnet WILL command. """ + logger.info('WILL %r', data) + + def wont_received(self, data): + """ Received telnet WONT command. """ + logger.info('WONT %r', data) + + def command_received(self, command, data): + if command == DO: + self.do_received(data) + + elif command == DONT: + self.dont_received(data) + + elif command == WILL: + self.will_received(data) + + elif command == WONT: + self.wont_received(data) + + else: + logger.info('command received %r %r', command, data) + + def naws(self, data): + """ + Received NAWS. (Window dimensions.) + """ + if len(data) == 4: + # NOTE: the first parameter of struct.unpack should be + # a 'str' object. Both on Py2/py3. This crashes on OSX + # otherwise. + columns, rows = struct.unpack(str('!HH'), data) + self.size_received_callback(rows, columns) + else: + logger.warning('Wrong number of NAWS bytes') + + def negotiate(self, data): + """ + Got negotiate data. + """ + command, payload = data[0:1], data[1:] + assert isinstance(command, bytes) + + if command == NAWS: + self.naws(payload) + else: + logger.info('Negotiate (%r got bytes)', len(data)) + + def _parse_coroutine(self): + """ + Parser state machine. + Every 'yield' expression returns the next byte. + """ + while True: + d = yield + + if d == int2byte(0): + pass # NOP + + # Go to state escaped. + elif d == IAC: + d2 = yield + + if d2 == IAC: + self.received_data(d2) + + # Handle simple commands. + elif d2 in (NOP, DM, BRK, IP, AO, AYT, EC, EL, GA): + self.command_received(d2, None) + + # Handle IAC-[DO/DONT/WILL/WONT] commands. + elif d2 in (DO, DONT, WILL, WONT): + d3 = yield + self.command_received(d2, d3) + + # Subnegotiation + elif d2 == SB: + # Consume everything until next IAC-SE + data = [] + + while True: + d3 = yield + + if d3 == IAC: + d4 = yield + if d4 == SE: + break + else: + data.append(d4) + else: + data.append(d3) + + self.negotiate(b''.join(data)) + else: + self.received_data(d) + + def feed(self, data): + """ + Feed data to the parser. + """ + assert isinstance(data, binary_type) + for b in iterbytes(data): + self._parser.send(int2byte(b)) diff --git a/src/libs/prompt_toolkit/contrib/telnet/server.py b/src/libs/prompt_toolkit/contrib/telnet/server.py new file mode 100644 index 0000000..d75a957 --- /dev/null +++ b/src/libs/prompt_toolkit/contrib/telnet/server.py @@ -0,0 +1,407 @@ +""" +Telnet server. + +Example usage:: + + class MyTelnetApplication(TelnetApplication): + def client_connected(self, telnet_connection): + # Set CLI with simple prompt. + telnet_connection.set_application( + telnet_connection.create_prompt_application(...)) + + def handle_command(self, telnet_connection, document): + # When the client enters a command, just reply. + telnet_connection.send('You said: %r\n\n' % document.text) + + ... + + a = MyTelnetApplication() + TelnetServer(application=a, host='127.0.0.1', port=23).run() +""" +from __future__ import unicode_literals + +import socket +import select + +import threading +import os +import fcntl + +from six import int2byte, text_type, binary_type +from codecs import getincrementaldecoder + +from prompt_toolkit.enums import DEFAULT_BUFFER +from prompt_toolkit.eventloop.base import EventLoop +from prompt_toolkit.interface import CommandLineInterface, Application +from prompt_toolkit.layout.screen import Size +from prompt_toolkit.shortcuts import create_prompt_application +from prompt_toolkit.terminal.vt100_input import InputStream +from prompt_toolkit.terminal.vt100_output import Vt100_Output + +from .log import logger +from .protocol import IAC, DO, LINEMODE, SB, MODE, SE, WILL, ECHO, NAWS, SUPPRESS_GO_AHEAD +from .protocol import TelnetProtocolParser +from .application import TelnetApplication + +__all__ = ( + 'TelnetServer', +) + + +def _initialize_telnet(connection): + logger.info('Initializing telnet connection') + + # Iac Do Linemode + connection.send(IAC + DO + LINEMODE) + + # Suppress Go Ahead. (This seems important for Putty to do correct echoing.) + # This will allow bi-directional operation. + connection.send(IAC + WILL + SUPPRESS_GO_AHEAD) + + # Iac sb + connection.send(IAC + SB + LINEMODE + MODE + int2byte(0) + IAC + SE) + + # IAC Will Echo + connection.send(IAC + WILL + ECHO) + + # Negotiate window size + connection.send(IAC + DO + NAWS) + + +class _ConnectionStdout(object): + """ + Wrapper around socket which provides `write` and `flush` methods for the + Vt100_Output output. + """ + def __init__(self, connection, encoding): + self._encoding = encoding + self._connection = connection + self._buffer = [] + + def write(self, data): + assert isinstance(data, text_type) + self._buffer.append(data.encode(self._encoding)) + self.flush() + + def flush(self): + try: + self._connection.send(b''.join(self._buffer)) + except socket.error as e: + logger.error("Couldn't send data over socket: %s" % e) + + self._buffer = [] + + +class TelnetConnection(object): + """ + Class that represents one Telnet connection. + """ + def __init__(self, conn, addr, application, server, encoding): + assert isinstance(addr, tuple) # (addr, port) tuple + assert isinstance(application, TelnetApplication) + assert isinstance(server, TelnetServer) + assert isinstance(encoding, text_type) # e.g. 'utf-8' + + self.conn = conn + self.addr = addr + self.application = application + self.closed = False + self.handling_command = True + self.server = server + self.encoding = encoding + self.callback = None # Function that handles the CLI result. + + # Create "Output" object. + self.size = Size(rows=40, columns=79) + + # Initialize. + _initialize_telnet(conn) + + # Create output. + def get_size(): + return self.size + self.stdout = _ConnectionStdout(conn, encoding=encoding) + self.vt100_output = Vt100_Output(self.stdout, get_size, write_binary=False) + + # Create an eventloop (adaptor) for the CommandLineInterface. + self.eventloop = _TelnetEventLoopInterface(server) + + # Set default CommandLineInterface. + self.set_application(create_prompt_application()) + + # Call client_connected + application.client_connected(self) + + # Draw for the first time. + self.handling_command = False + self.cli._redraw() + + def set_application(self, app, callback=None): + """ + Set ``CommandLineInterface`` instance for this connection. + (This can be replaced any time.) + + :param cli: CommandLineInterface instance. + :param callback: Callable that takes the result of the CLI. + """ + assert isinstance(app, Application) + assert callback is None or callable(callback) + + self.cli = CommandLineInterface( + application=app, + eventloop=self.eventloop, + output=self.vt100_output) + self.callback = callback + + # Create a parser, and parser callbacks. + cb = self.cli.create_eventloop_callbacks() + inputstream = InputStream(cb.feed_key) + + # Input decoder for stdin. (Required when working with multibyte + # characters, like chinese input.) + stdin_decoder_cls = getincrementaldecoder(self.encoding) + stdin_decoder = [stdin_decoder_cls()] # nonlocal + + # Tell the CLI that it's running. We don't start it through the run() + # call, but will still want _redraw() to work. + self.cli._is_running = True + + def data_received(data): + """ TelnetProtocolParser 'data_received' callback """ + assert isinstance(data, binary_type) + + try: + result = stdin_decoder[0].decode(data) + inputstream.feed(result) + except UnicodeDecodeError: + stdin_decoder[0] = stdin_decoder_cls() + return '' + + def size_received(rows, columns): + """ TelnetProtocolParser 'size_received' callback """ + self.size = Size(rows=rows, columns=columns) + cb.terminal_size_changed() + + self.parser = TelnetProtocolParser(data_received, size_received) + + def feed(self, data): + """ + Handler for incoming data. (Called by TelnetServer.) + """ + assert isinstance(data, binary_type) + + self.parser.feed(data) + + # Render again. + self.cli._redraw() + + # When a return value has been set (enter was pressed), handle command. + if self.cli.is_returning: + try: + return_value = self.cli.return_value() + except (EOFError, KeyboardInterrupt) as e: + # Control-D or Control-C was pressed. + logger.info('%s, closing connection.', type(e).__name__) + self.close() + return + + # Handle CLI command + self._handle_command(return_value) + + def _handle_command(self, command): + """ + Handle command. This will run in a separate thread, in order not + to block the event loop. + """ + logger.info('Handle command %r', command) + + def in_executor(): + self.handling_command = True + try: + if self.callback is not None: + self.callback(self, command) + finally: + self.server.call_from_executor(done) + + def done(): + self.handling_command = False + + # Reset state and draw again. (If the connection is still open -- + # the application could have called TelnetConnection.close() + if not self.closed: + self.cli.reset() + self.cli.buffers[DEFAULT_BUFFER].reset() + self.cli.renderer.request_absolute_cursor_position() + self.vt100_output.flush() + self.cli._redraw() + + self.server.run_in_executor(in_executor) + + def erase_screen(self): + """ + Erase output screen. + """ + self.vt100_output.erase_screen() + self.vt100_output.cursor_goto(0, 0) + self.vt100_output.flush() + + def send(self, data): + """ + Send text to the client. + """ + assert isinstance(data, text_type) + + # When data is send back to the client, we should replace the line + # endings. (We didn't allocate a real pseudo terminal, and the telnet + # connection is raw, so we are responsible for inserting \r.) + self.stdout.write(data.replace('\n', '\r\n')) + self.stdout.flush() + + def close(self): + """ + Close the connection. + """ + self.application.client_leaving(self) + + self.conn.close() + self.closed = True + + +class _TelnetEventLoopInterface(EventLoop): + """ + Eventloop object to be assigned to `CommandLineInterface`. + """ + def __init__(self, server): + self._server = server + + def close(self): + " Ignore. " + + def stop(self): + " Ignore. " + + def run_in_executor(self, callback): + self._server.run_in_executor(callback) + + def call_from_executor(self, callback, _max_postpone_until=None): + self._server.call_from_executor(callback) + + def add_reader(self, fd, callback): + raise NotImplementedError + + def remove_reader(self, fd): + raise NotImplementedError + + +class TelnetServer(object): + """ + Telnet server implementation. + """ + def __init__(self, host='127.0.0.1', port=23, application=None, encoding='utf-8'): + assert isinstance(host, text_type) + assert isinstance(port, int) + assert isinstance(application, TelnetApplication) + assert isinstance(encoding, text_type) + + self.host = host + self.port = port + self.application = application + self.encoding = encoding + + self.connections = set() + + self._calls_from_executor = [] + + # Create a pipe for inter thread communication. + self._schedule_pipe = os.pipe() + fcntl.fcntl(self._schedule_pipe[0], fcntl.F_SETFL, os.O_NONBLOCK) + + @classmethod + def create_socket(cls, host, port): + # Create and bind socket + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind((host, port)) + + s.listen(4) + return s + + def run_in_executor(self, callback): + threading.Thread(target=callback).start() + + def call_from_executor(self, callback): + self._calls_from_executor.append(callback) + + if self._schedule_pipe: + os.write(self._schedule_pipe[1], b'x') + + def _process_callbacks(self): + """ + Process callbacks from `call_from_executor` in eventloop. + """ + # Flush all the pipe content. + os.read(self._schedule_pipe[0], 1024) + + # Process calls from executor. + calls_from_executor, self._calls_from_executor = self._calls_from_executor, [] + for c in calls_from_executor: + c() + + def run(self): + """ + Run the eventloop for the telnet server. + """ + listen_socket = self.create_socket(self.host, self.port) + logger.info('Listening for telnet connections on %s port %r', self.host, self.port) + + try: + while True: + # Removed closed connections. + self.connections = set([c for c in self.connections if not c.closed]) + + # Ignore connections handling commands. + connections = set([c for c in self.connections if not c.handling_command]) + + # Wait for next event. + read_list = ( + [listen_socket, self._schedule_pipe[0]] + + [c.conn for c in connections]) + + read, _, _ = select.select(read_list, [], []) + + for s in read: + # When the socket itself is ready, accept a new connection. + if s == listen_socket: + self._accept(listen_socket) + + # If we receive something on our "call_from_executor" pipe, process + # these callbacks in a thread safe way. + elif s == self._schedule_pipe[0]: + self._process_callbacks() + + # Handle incoming data on socket. + else: + self._handle_incoming_data(s) + finally: + listen_socket.close() + + def _accept(self, listen_socket): + """ + Accept new incoming connection. + """ + conn, addr = listen_socket.accept() + connection = TelnetConnection(conn, addr, self.application, self, encoding=self.encoding) + self.connections.add(connection) + + logger.info('New connection %r %r', *addr) + + def _handle_incoming_data(self, conn): + """ + Handle incoming data on socket. + """ + connection = [c for c in self.connections if c.conn == conn][0] + data = conn.recv(1024) + if data: + connection.feed(data) + else: + self.connections.remove(connection) diff --git a/src/libs/prompt_toolkit/contrib/validators/__init__.py b/src/libs/prompt_toolkit/contrib/validators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/libs/prompt_toolkit/contrib/validators/base.py b/src/libs/prompt_toolkit/contrib/validators/base.py new file mode 100644 index 0000000..16c1539 --- /dev/null +++ b/src/libs/prompt_toolkit/contrib/validators/base.py @@ -0,0 +1,34 @@ +from __future__ import unicode_literals +from prompt_toolkit.validation import Validator, ValidationError +from six import string_types + + +class SentenceValidator(Validator): + """ + Validate input only when it appears in this list of sentences. + + :param sentences: List of sentences. + :param ignore_case: If True, case-insensitive comparisons. + """ + def __init__(self, sentences, ignore_case=False, error_message='Invalid input', move_cursor_to_end=False): + assert all(isinstance(s, string_types) for s in sentences) + assert isinstance(ignore_case, bool) + assert isinstance(error_message, string_types) + + self.sentences = list(sentences) + self.ignore_case = ignore_case + self.error_message = error_message + self.move_cursor_to_end = move_cursor_to_end + + if ignore_case: + self.sentences = set([s.lower() for s in self.sentences]) + + def validate(self, document): + if document.text not in self.sentences: + if self.move_cursor_to_end: + index = len(document.text) + else: + index = 0 + + raise ValidationError(cursor_position=index, + message=self.error_message) diff --git a/src/libs/prompt_toolkit/document.py b/src/libs/prompt_toolkit/document.py new file mode 100644 index 0000000..0d70aa6 --- /dev/null +++ b/src/libs/prompt_toolkit/document.py @@ -0,0 +1,1001 @@ +""" +The `Document` that implements all the text operations/querying. +""" +from __future__ import unicode_literals + +import bisect +import re +import six +import string +import weakref +from six.moves import range, map + +from .selection import SelectionType, SelectionState, PasteMode +from .clipboard import ClipboardData + +__all__ = ('Document',) + + +# Regex for finding "words" in documents. (We consider a group of alnum +# characters a word, but also a group of special characters a word, as long as +# it doesn't contain a space.) +# (This is a 'word' in Vi.) +_FIND_WORD_RE = re.compile(r'([a-zA-Z0-9_]+|[^a-zA-Z0-9_\s]+)') +_FIND_CURRENT_WORD_RE = re.compile(r'^([a-zA-Z0-9_]+|[^a-zA-Z0-9_\s]+)') +_FIND_CURRENT_WORD_INCLUDE_TRAILING_WHITESPACE_RE = re.compile(r'^(([a-zA-Z0-9_]+|[^a-zA-Z0-9_\s]+)\s*)') + +# Regex for finding "WORDS" in documents. +# (This is a 'WORD in Vi.) +_FIND_BIG_WORD_RE = re.compile(r'([^\s]+)') +_FIND_CURRENT_BIG_WORD_RE = re.compile(r'^([^\s]+)') +_FIND_CURRENT_BIG_WORD_INCLUDE_TRAILING_WHITESPACE_RE = re.compile(r'^([^\s]+\s*)') + +# Share the Document._cache between all Document instances. +# (Document instances are considered immutable. That means that if another +# `Document` is constructed with the same text, it should have the same +# `_DocumentCache`.) +_text_to_document_cache = weakref.WeakValueDictionary() # Maps document.text to DocumentCache instance. + + +class _ImmutableLineList(list): + """ + Some protection for our 'lines' list, which is assumed to be immutable in the cache. + (Useful for detecting obvious bugs.) + """ + def _error(self, *a, **kw): + raise NotImplementedError('Attempt to modifiy an immutable list.') + + __setitem__ = _error + append = _error + clear = _error + extend = _error + insert = _error + pop = _error + remove = _error + reverse = _error + sort = _error + + +class _DocumentCache(object): + def __init__(self): + #: List of lines for the Document text. + self.lines = None + + #: List of index positions, pointing to the start of all the lines. + self.line_indexes = None + + +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:`~libs.prompt_toolkit.buffer.Buffer` + object, and accessed as the `document` property of that class. + + :param text: string + :param cursor_position: int + :param selection: :class:`.SelectionState` + """ + __slots__ = ('_text', '_cursor_position', '_selection', '_cache') + + def __init__(self, text='', cursor_position=None, selection=None): + assert isinstance(text, six.text_type), 'Got %r' % text + assert selection is None or isinstance(selection, SelectionState) + + # Check cursor position. It can also be right after the end. (Where we + # insert text.) + assert cursor_position is None or cursor_position <= len(text), AssertionError( + 'cursor_position=%r, len_text=%r' % (cursor_position, len(text))) + + # By default, if no cursor position was given, make sure to put the + # cursor position is at the end of the document. This is what makes + # sense in most places. + if cursor_position is None: + cursor_position = len(text) + + # Keep these attributes private. A `Document` really has to be + # considered to be immutable, because otherwise the caching will break + # things. Because of that, we wrap these into read-only properties. + self._text = text + self._cursor_position = cursor_position + self._selection = selection + + # Cache for lines/indexes. (Shared with other Document instances that + # contain the same text. + try: + self._cache = _text_to_document_cache[self.text] + except KeyError: + self._cache = _DocumentCache() + _text_to_document_cache[self.text] = self._cache + + # XX: For some reason, above, we can't use 'WeakValueDictionary.setdefault'. + # This fails in Pypy3. `self._cache` becomes None, because that's what + # 'setdefault' returns. + # self._cache = _text_to_document_cache.setdefault(self.text, _DocumentCache()) + # assert self._cache + + def __repr__(self): + return '%s(%r, %r)' % (self.__class__.__name__, self.text, self.cursor_position) + + @property + def text(self): + " The document text. " + return self._text + + @property + def cursor_position(self): + " The document cursor position. " + return self._cursor_position + + @property + def selection(self): + " :class:`.SelectionState` object. " + return self._selection + + @property + def current_char(self): + """ Return character under cursor or an empty string. """ + return self._get_char_relative_to_cursor(0) or '' + + @property + def char_before_cursor(self): + """ Return character before the cursor or an empty string. """ + return self._get_char_relative_to_cursor(-1) or '' + + @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. """ + _, _, text = self.text_before_cursor.rpartition('\n') + return text + + @property + def current_line_after_cursor(self): + """ Text from the cursor until the end of the line. """ + text, _, _ = self.text_after_cursor.partition('\n') + return text + + @property + def lines(self): + """ + Array of all the lines. + """ + # Cache, because this one is reused very often. + if self._cache.lines is None: + self._cache.lines = _ImmutableLineList(self.text.split('\n')) + + return self._cache.lines + + @property + def _line_start_indexes(self): + """ + Array pointing to the start indexes of all the lines. + """ + # Cache, because this is often reused. (If it is used, it's often used + # many times. And this has to be fast for editing big documents!) + if self._cache.line_indexes is None: + # Create list of line lengths. + line_lengths = map(len, self.lines) + + # Calculate cumulative sums. + indexes = [0] + append = indexes.append + pos = 0 + + for line_length in line_lengths: + pos += line_length + 1 + append(pos) + + # Remove the last item. (This is not a new line.) + if len(indexes) > 1: + indexes.pop() + + self._cache.line_indexes = indexes + + return self._cache.line_indexes + + @property + def lines_from_current(self): + """ + 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): + r""" Return the number of lines in this document. If the document ends + with a trailing \n, that counts as the beginning of a new line. """ + return len(self.lines) + + @property + def current_line(self): + """ 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 empty string + """ + try: + return self.text[self.cursor_position + offset] + except IndexError: + return '' + + @property + def on_first_line(self): + """ + True when we are at the first line. + """ + return self.cursor_position_row == 0 + + @property + def on_last_line(self): + """ + True when we are at the last line. + """ + return self.cursor_position_row == self.line_count - 1 + + @property + def cursor_position_row(self): + """ + Current row. (0-based.) + """ + row, _ = self._find_line_start_index(self.cursor_position) + return row + + @property + def cursor_position_col(self): + """ + Current column. (0-based.) + """ + # (Don't use self.text_before_cursor to calculate this. Creating + # substrings and doing rsplit is too expensive for getting the cursor + # position.) + _, line_start_index = self._find_line_start_index(self.cursor_position) + return self.cursor_position - line_start_index + + def _find_line_start_index(self, index): + """ + For the index of a character at a certain line, calculate the index of + the first character on that line. + + Return (row, index) tuple. + """ + indexes = self._line_start_indexes + + pos = bisect.bisect_right(indexes, index) - 1 + return pos, indexes[pos] + + def translate_index_to_position(self, index): + """ + Given an index for the text, return the corresponding (row, col) tuple. + (0-based. Returns (0, 0) for index=0.) + """ + # Find start of this line. + row, row_index = self._find_line_start_index(index) + col = index - row_index + + return row, col + + + def translate_row_col_to_index(self, row, col): + """ + Given a (row, col) tuple, return the corresponding index. + (Row and col params are 0-based.) + + Negative row/col values are turned into zero. + """ + try: + result = self._line_start_indexes[row] + line = self.lines[row] + except IndexError: + if row < 0: + result = self._line_start_indexes[0] + line = self.lines[0] + else: + result = self._line_start_indexes[-1] + line = self.lines[-1] + + result += max(0, min(col, len(line))) + + # Keep in range. (len(self.text) is included, because the cursor can be + # right after the end of the text as well.) + result = max(0, min(result, len(self.text))) + return result + + @property + def is_cursor_at_the_end(self): + """ True when the cursor is at the end of the text. """ + return self.cursor_position == len(self.text) + + @property + def is_cursor_at_the_end_of_line(self): + """ True when the cursor is at the end of this line. """ + return self.current_char in ('\n', '') + + def has_match_at_current_position(self, sub): + """ + `True` when this substring is found at the cursor position. + """ + return self.text.find(sub, self.cursor_position) == self.cursor_position + + def find(self, sub, in_current_line=False, include_current_position=False, + ignore_case=False, count=1): + """ + Find `text` after the cursor, return position relative to the cursor + position. Return `None` if nothing was found. + + :param count: Find the n-th occurance. + """ + assert isinstance(ignore_case, bool) + + if in_current_line: + text = self.current_line_after_cursor + else: + text = self.text_after_cursor + + if not include_current_position: + if len(text) == 0: + return # (Otherwise, we always get a match for the empty string.) + else: + text = text[1:] + + flags = re.IGNORECASE if ignore_case else 0 + iterator = re.finditer(re.escape(sub), text, flags) + + try: + for i, match in enumerate(iterator): + if i + 1 == count: + if include_current_position: + return match.start(0) + else: + return match.start(0) + 1 + except StopIteration: + pass + + def find_all(self, sub, ignore_case=False): + """ + Find all occurances of the substring. Return a list of absolute + positions in the document. + """ + flags = re.IGNORECASE if ignore_case else 0 + return [a.start() for a in re.finditer(re.escape(sub), self.text, flags)] + + def find_backwards(self, sub, in_current_line=False, ignore_case=False, count=1): + """ + Find `text` before the cursor, return position relative to the cursor + position. Return `None` if nothing was found. + + :param count: Find the n-th occurance. + """ + if in_current_line: + before_cursor = self.current_line_before_cursor[::-1] + else: + before_cursor = self.text_before_cursor[::-1] + + flags = re.IGNORECASE if ignore_case else 0 + iterator = re.finditer(re.escape(sub[::-1]), before_cursor, flags) + + try: + for i, match in enumerate(iterator): + if i + 1 == count: + return - match.start(0) - len(sub) + except StopIteration: + pass + + def get_word_before_cursor(self, WORD=False): + """ + Give the word before the cursor. + If we have whitespace before the cursor this returns an empty string. + """ + if self.text_before_cursor[-1:].isspace(): + return '' + else: + return self.text_before_cursor[self.find_start_of_previous_word(WORD=WORD):] + + def find_start_of_previous_word(self, count=1, WORD=False): + """ + 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] + + regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE + iterator = regex.finditer(text_before_cursor) + + try: + for i, match in enumerate(iterator): + if i + 1 == count: + return - match.end(1) + except StopIteration: + pass + + def find_boundaries_of_current_word(self, WORD=False, include_leading_whitespace=False, + include_trailing_whitespace=False): + """ + Return the relative boundaries (startpos, endpos) of the current word under the + cursor. (This is at the current line, because line boundaries obviously + don't belong to any word.) + If not on a word, this returns (0,0) + """ + text_before_cursor = self.current_line_before_cursor[::-1] + text_after_cursor = self.current_line_after_cursor + + def get_regex(include_whitespace): + return { + (False, False): _FIND_CURRENT_WORD_RE, + (False, True): _FIND_CURRENT_WORD_INCLUDE_TRAILING_WHITESPACE_RE, + (True, False): _FIND_CURRENT_BIG_WORD_RE, + (True, True): _FIND_CURRENT_BIG_WORD_INCLUDE_TRAILING_WHITESPACE_RE, + }[(WORD, include_whitespace)] + + match_before = get_regex(include_leading_whitespace).search(text_before_cursor) + match_after = get_regex(include_trailing_whitespace).search(text_after_cursor) + + # When there is a match before and after, and we're not looking for + # WORDs, make sure that both the part before and after the cursor are + # either in the [a-zA-Z_] alphabet or not. Otherwise, drop the part + # before the cursor. + if not WORD and match_before and match_after: + c1 = self.text[self.cursor_position - 1] + c2 = self.text[self.cursor_position] + alphabet = string.ascii_letters + '0123456789_' + + if (c1 in alphabet) != (c2 in alphabet): + match_before = None + + return ( + - match_before.end(1) if match_before else 0, + match_after.end(1) if match_after else 0 + ) + + def get_word_under_cursor(self, WORD=False): + """ + Return the word, currently below the cursor. + This returns an empty string when the cursor is on a whitespace region. + """ + start, end = self.find_boundaries_of_current_word(WORD=WORD) + return self.text[self.cursor_position + start: self.cursor_position + end] + + def find_next_word_beginning(self, count=1, WORD=False): + """ + Return an index relative to the cursor position pointing to the start + of the next word. Return `None` if nothing was found. + """ + if count < 0: + return self.find_previous_word_beginning(count=-count, WORD=WORD) + + regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE + iterator = regex.finditer(self.text_after_cursor) + + try: + for i, match in enumerate(iterator): + # Take first match, unless it's the word on which we're right now. + if i == 0 and match.start(1) == 0: + count += 1 + + if i + 1 == count: + return match.start(1) + except StopIteration: + pass + + def find_next_word_ending(self, include_current_position=False, count=1, WORD=False): + """ + Return an index relative to the cursor position pointing to the end + of the next word. Return `None` if nothing was found. + """ + if count < 0: + return self.find_previous_word_ending(count=-count, WORD=WORD) + + if include_current_position: + text = self.text_after_cursor + else: + text = self.text_after_cursor[1:] + + regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE + iterable = regex.finditer(text) + + try: + for i, match in enumerate(iterable): + if i + 1 == count: + value = match.end(1) + + if include_current_position: + return value + else: + return value + 1 + + except StopIteration: + pass + + def find_previous_word_beginning(self, count=1, WORD=False): + """ + Return an index relative to the cursor position pointing to the start + of the previous word. Return `None` if nothing was found. + """ + if count < 0: + return self.find_next_word_beginning(count=-count, WORD=WORD) + + regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE + iterator = regex.finditer(self.text_before_cursor[::-1]) + + try: + for i, match in enumerate(iterator): + if i + 1 == count: + return - match.end(1) + except StopIteration: + pass + + def find_previous_word_ending(self, count=1, WORD=False): + """ + Return an index relative to the cursor position pointing to the end + of the previous word. Return `None` if nothing was found. + """ + if count < 0: + return self.find_next_word_ending(count=-count, WORD=WORD) + + text_before_cursor = self.text_after_cursor[:1] + self.text_before_cursor[::-1] + + regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE + iterator = regex.finditer(text_before_cursor) + + try: + for i, match in enumerate(iterator): + # Take first match, unless it's the word on which we're right now. + if i == 0 and match.start(1) == 0: + count += 1 + + if i + 1 == count: + return -match.start(1) + 1 + except StopIteration: + pass + + def find_next_matching_line(self, match_func, count=1): + """ + Look downwards for empty lines. + Return the line index, relative to the current line. + """ + result = None + + for index, line in enumerate(self.lines[self.cursor_position_row + 1:]): + if match_func(line): + result = 1 + index + count -= 1 + + if count == 0: + break + + return result + + def find_previous_matching_line(self, match_func, count=1): + """ + Look upwards for empty lines. + Return the line index, relative to the current line. + """ + result = None + + for index, line in enumerate(self.lines[:self.cursor_position_row][::-1]): + if match_func(line): + result = -1 - index + count -= 1 + + if count == 0: + break + + return result + + def get_cursor_left_position(self, count=1): + """ + Relative position for cursor left. + """ + if count < 0: + return self.get_cursor_right_position(-count) + + return - min(self.cursor_position_col, count) + + def get_cursor_right_position(self, count=1): + """ + Relative position for cursor_right. + """ + if count < 0: + return self.get_cursor_left_position(-count) + + return min(count, len(self.current_line_after_cursor)) + + def get_cursor_up_position(self, count=1, preferred_column=None): + """ + Return the relative cursor position (character index) where we would be if the + user pressed the arrow-up button. + + :param preferred_column: When given, go to this column instead of + staying at the current column. + """ + assert count >= 1 + column = self.cursor_position_col if preferred_column is None else preferred_column + + return self.translate_row_col_to_index( + max(0, self.cursor_position_row - count), column) - self.cursor_position + + def get_cursor_down_position(self, count=1, preferred_column=None): + """ + Return the relative cursor position (character index) where we would be if the + user pressed the arrow-down button. + + :param preferred_column: When given, go to this column instead of + staying at the current column. + """ + assert count >= 1 + column = self.cursor_position_col if preferred_column is None else preferred_column + + return self.translate_row_col_to_index( + self.cursor_position_row + count, column) - self.cursor_position + + def find_enclosing_bracket_right(self, left_ch, right_ch, end_pos=None): + """ + Find the right bracket enclosing current position. Return the relative + position to the cursor position. + + When `end_pos` is given, don't look past the position. + """ + if self.current_char == right_ch: + return 0 + + if end_pos is None: + end_pos = len(self.text) + else: + end_pos = min(len(self.text), end_pos) + + stack = 1 + + # Look forward. + for i in range(self.cursor_position + 1, end_pos): + c = self.text[i] + + if c == left_ch: + stack += 1 + elif c == right_ch: + stack -= 1 + + if stack == 0: + return i - self.cursor_position + + def find_enclosing_bracket_left(self, left_ch, right_ch, start_pos=None): + """ + Find the left bracket enclosing current position. Return the relative + position to the cursor position. + + When `start_pos` is given, don't look past the position. + """ + if self.current_char == left_ch: + return 0 + + if start_pos is None: + start_pos = 0 + else: + start_pos = max(0, start_pos) + + stack = 1 + + # Look backward. + for i in range(self.cursor_position - 1, start_pos - 1, -1): + c = self.text[i] + + if c == right_ch: + stack += 1 + elif c == left_ch: + stack -= 1 + + if stack == 0: + return i - self.cursor_position + + def find_matching_bracket_position(self, start_pos=None, end_pos=None): + """ + Return relative cursor position of matching [, (, { or < bracket. + + When `start_pos` or `end_pos` are given. Don't look past the positions. + """ + + # Look for a match. + for A, B in '()', '[]', '{}', '<>': + if self.current_char == A: + return self.find_enclosing_bracket_right(A, B, end_pos=end_pos) or 0 + elif self.current_char == B: + return self.find_enclosing_bracket_left(A, B, start_pos=start_pos) or 0 + + return 0 + + def get_start_of_document_position(self): + """ Relative position for the start of the document. """ + return - self.cursor_position + + def get_end_of_document_position(self): + """ Relative position for the end of the document. """ + return len(self.text) - self.cursor_position + + def get_start_of_line_position(self, after_whitespace=False): + """ Relative position for the start of this line. """ + if after_whitespace: + current_line = self.current_line + return len(current_line) - len(current_line.lstrip()) - self.cursor_position_col + else: + return - len(self.current_line_before_cursor) + + def get_end_of_line_position(self): + """ Relative position for the end of this line. """ + return len(self.current_line_after_cursor) + + def last_non_blank_of_current_line_position(self): + """ + Relative position for the last non blank character of this line. + """ + return len(self.current_line.rstrip()) - self.cursor_position_col - 1 + + def get_column_cursor_position(self, column): + """ + Return the relative cursor position for this column at the current + line. (It will stay between the boundaries of the line in case of a + larger number.) + """ + line_length = len(self.current_line) + current_column = self.cursor_position_col + column = max(0, min(line_length, column)) + + return column - current_column + + def selection_range(self): # XXX: shouldn't this return `None` if there is no selection??? + """ + Return (from, to) tuple of the selection. + start and end position are included. + + This doesn't take the selection type into account. Use + `selection_ranges` instead. + """ + if self.selection: + from_, to = sorted([self.cursor_position, self.selection.original_cursor_position]) + else: + from_, to = self.cursor_position, self.cursor_position + + return from_, to + + def selection_ranges(self): + """ + Return a list of (from, to) tuples for the selection or none if nothing + was selected. start and end position are always included in the + selection. + + This will yield several (from, to) tuples in case of a BLOCK selection. + """ + if self.selection: + from_, to = sorted([self.cursor_position, self.selection.original_cursor_position]) + + if self.selection.type == SelectionType.BLOCK: + from_line, from_column = self.translate_index_to_position(from_) + to_line, to_column = self.translate_index_to_position(to) + from_column, to_column = sorted([from_column, to_column]) + lines = self.lines + + for l in range(from_line, to_line + 1): + line_length = len(lines[l]) + if from_column < line_length: + yield (self.translate_row_col_to_index(l, from_column), + self.translate_row_col_to_index(l, min(line_length - 1, to_column))) + else: + # In case of a LINES selection, go to the start/end of the lines. + if self.selection.type == SelectionType.LINES: + from_ = max(0, self.text.rfind('\n', 0, from_) + 1) + + if self.text.find('\n', to) >= 0: + to = self.text.find('\n', to) + else: + to = len(self.text) - 1 + + yield from_, to + + def selection_range_at_line(self, row): + """ + If the selection spans a portion of the given line, return a (from, to) tuple. + Otherwise, return None. + """ + if self.selection: + row_start = self.translate_row_col_to_index(row, 0) + row_end = self.translate_row_col_to_index(row, max(0, len(self.lines[row]) - 1)) + + from_, to = sorted([self.cursor_position, self.selection.original_cursor_position]) + + # Take the intersection of the current line and the selection. + intersection_start = max(row_start, from_) + intersection_end = min(row_end, to) + + if intersection_start <= intersection_end: + if self.selection.type == SelectionType.LINES: + intersection_start = row_start + intersection_end = row_end + elif self.selection.type == SelectionType.BLOCK: + _, col1 = self.translate_index_to_position(from_) + _, col2 = self.translate_index_to_position(to) + col1, col2 = sorted([col1, col2]) + intersection_start = self.translate_row_col_to_index(row, col1) + intersection_end = self.translate_row_col_to_index(row, col2) + + _, from_column = self.translate_index_to_position(intersection_start) + _, to_column = self.translate_index_to_position(intersection_end) + + return from_column, to_column + + def cut_selection(self): + """ + Return a (:class:`.Document`, :class:`.ClipboardData`) tuple, where the + document represents the new document when the selection is cut, and the + clipboard data, represents whatever has to be put on the clipboard. + """ + if self.selection: + cut_parts = [] + remaining_parts = [] + new_cursor_position = self.cursor_position + + last_to = 0 + for from_, to in self.selection_ranges(): + if last_to == 0: + new_cursor_position = from_ + + remaining_parts.append(self.text[last_to:from_]) + cut_parts.append(self.text[from_:to + 1]) + last_to = to + 1 + + remaining_parts.append(self.text[last_to:]) + + cut_text = '\n'.join(cut_parts) + remaining_text = ''.join(remaining_parts) + + # In case of a LINES selection, don't include the trailing newline. + if self.selection.type == SelectionType.LINES and cut_text.endswith('\n'): + cut_text = cut_text[:-1] + + return (Document(text=remaining_text, cursor_position=new_cursor_position), + ClipboardData(cut_text, self.selection.type)) + else: + return self, ClipboardData('') + + def paste_clipboard_data(self, data, paste_mode=PasteMode.EMACS, count=1): + """ + Return a new :class:`.Document` instance which contains the result if + we would paste this data at the current cursor position. + + :param paste_mode: Where to paste. (Before/after/emacs.) + :param count: When >1, Paste multiple times. + """ + assert isinstance(data, ClipboardData) + assert paste_mode in (PasteMode.VI_BEFORE, PasteMode.VI_AFTER, PasteMode.EMACS) + + before = (paste_mode == PasteMode.VI_BEFORE) + after = (paste_mode == PasteMode.VI_AFTER) + + if data.type == SelectionType.CHARACTERS: + if after: + new_text = (self.text[:self.cursor_position + 1] + data.text * count + + self.text[self.cursor_position + 1:]) + else: + new_text = self.text_before_cursor + data.text * count + self.text_after_cursor + + new_cursor_position = self.cursor_position + len(data.text) * count + if before: + new_cursor_position -= 1 + + elif data.type == SelectionType.LINES: + l = self.cursor_position_row + if before: + lines = self.lines[:l] + [data.text] * count + self.lines[l:] + new_text = '\n'.join(lines) + new_cursor_position = len(''.join(self.lines[:l])) + l + else: + lines = self.lines[:l + 1] + [data.text] * count + self.lines[l + 1:] + new_cursor_position = len(''.join(self.lines[:l + 1])) + l + 1 + new_text = '\n'.join(lines) + + elif data.type == SelectionType.BLOCK: + lines = self.lines[:] + start_line = self.cursor_position_row + start_column = self.cursor_position_col + (0 if before else 1) + + for i, line in enumerate(data.text.split('\n')): + index = i + start_line + if index >= len(lines): + lines.append('') + + lines[index] = lines[index].ljust(start_column) + lines[index] = lines[index][:start_column] + line * count + lines[index][start_column:] + + new_text = '\n'.join(lines) + new_cursor_position = self.cursor_position + (0 if before else 1) + + return Document(text=new_text, cursor_position=new_cursor_position) + + def empty_line_count_at_the_end(self): + """ + Return number of empty lines at the end of the document. + """ + count = 0 + for line in self.lines[::-1]: + if not line or line.isspace(): + count += 1 + else: + break + + return count + + def start_of_paragraph(self, count=1, before=False): + """ + Return the start of the current paragraph. (Relative cursor position.) + """ + def match_func(text): + return not text or text.isspace() + + line_index = self.find_previous_matching_line(match_func=match_func, count=count) + + if line_index: + add = 0 if before else 1 + return min(0, self.get_cursor_up_position(count=-line_index) + add) + else: + return -self.cursor_position + + def end_of_paragraph(self, count=1, after=False): + """ + Return the end of the current paragraph. (Relative cursor position.) + """ + def match_func(text): + return not text or text.isspace() + + line_index = self.find_next_matching_line(match_func=match_func, count=count) + + if line_index: + add = 0 if after else 1 + return max(0, self.get_cursor_down_position(count=line_index) - add) + else: + return len(self.text_after_cursor) + + # Modifiers. + + def insert_after(self, text): + """ + Create a new document, with this text inserted after the buffer. + It keeps selection ranges and cursor position in sync. + """ + return Document( + text=self.text + text, + cursor_position=self.cursor_position, + selection=self.selection) + + def insert_before(self, text): + """ + Create a new document, with this text inserted before the buffer. + It keeps selection ranges and cursor position in sync. + """ + selection_state = self.selection + + if selection_state: + selection_state = SelectionState( + original_cursor_position=selection_state.original_cursor_position + len(text), + type=selection_state.type) + + return Document( + text=text + self.text, + cursor_position=self.cursor_position + len(text), + selection=selection_state) diff --git a/src/libs/prompt_toolkit/enums.py b/src/libs/prompt_toolkit/enums.py new file mode 100644 index 0000000..6945f44 --- /dev/null +++ b/src/libs/prompt_toolkit/enums.py @@ -0,0 +1,29 @@ +from __future__ import unicode_literals + + +class IncrementalSearchDirection(object): + FORWARD = 'FORWARD' + BACKWARD = 'BACKWARD' + + +class EditingMode(object): + # The set of key bindings that is active. + VI = 'VI' + EMACS = 'EMACS' + + +#: Name of the search buffer. +SEARCH_BUFFER = 'SEARCH_BUFFER' + +#: Name of the default buffer. +DEFAULT_BUFFER = 'DEFAULT_BUFFER' + +#: Name of the system buffer. +SYSTEM_BUFFER = 'SYSTEM_BUFFER' + +# Dummy buffer. This is the buffer returned by +# `CommandLineInterface.current_buffer` when the top of the `FocusStack` is +# `None`. This could be the case when there is some widget has the focus and no +# actual text editing is possible. This buffer should also never be displayed. +# (It will never contain any actual text.) +DUMMY_BUFFER = 'DUMMY_BUFFER' diff --git a/src/libs/prompt_toolkit/eventloop/__init__.py b/src/libs/prompt_toolkit/eventloop/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/libs/prompt_toolkit/eventloop/asyncio_base.py b/src/libs/prompt_toolkit/eventloop/asyncio_base.py new file mode 100644 index 0000000..ace2b8d --- /dev/null +++ b/src/libs/prompt_toolkit/eventloop/asyncio_base.py @@ -0,0 +1,46 @@ +""" +Eventloop for integration with Python3 asyncio. + +Note that we can't use "yield from", because the package should be installable +under Python 2.6 as well, and it should contain syntactically valid Python 2.6 +code. +""" +from __future__ import unicode_literals + +__all__ = ( + 'AsyncioTimeout', +) + + +class AsyncioTimeout(object): + """ + Call the `timeout` function when the timeout expires. + Every call of the `reset` method, resets the timeout and starts a new + timer. + """ + def __init__(self, timeout, callback, loop): + self.timeout = timeout + self.callback = callback + self.loop = loop + + self.counter = 0 + self.running = True + + def reset(self): + """ + Reset the timeout. Starts a new timer. + """ + self.counter += 1 + local_counter = self.counter + + def timer_timeout(): + if self.counter == local_counter and self.running: + self.callback() + + self.loop.call_later(self.timeout, timer_timeout) + + def stop(self): + """ + Ignore timeout. Don't call the callback anymore. + """ + self.running = False diff --git a/src/libs/prompt_toolkit/eventloop/asyncio_posix.py b/src/libs/prompt_toolkit/eventloop/asyncio_posix.py new file mode 100644 index 0000000..426ed96 --- /dev/null +++ b/src/libs/prompt_toolkit/eventloop/asyncio_posix.py @@ -0,0 +1,113 @@ +""" +Posix asyncio event loop. +""" +from __future__ import unicode_literals + +from ..terminal.vt100_input import InputStream +from .asyncio_base import AsyncioTimeout +from .base import EventLoop, INPUT_TIMEOUT +from .callbacks import EventLoopCallbacks +from .posix_utils import PosixStdinReader + +import asyncio +import signal + +__all__ = ( + 'PosixAsyncioEventLoop', +) + + +class PosixAsyncioEventLoop(EventLoop): + def __init__(self, loop=None): + self.loop = loop or asyncio.get_event_loop() + self.closed = False + + self._stopped_f = asyncio.Future(loop=self.loop) + + @asyncio.coroutine + def run_as_coroutine(self, stdin, callbacks): + """ + The input 'event loop'. + """ + assert isinstance(callbacks, EventLoopCallbacks) + + # Create reader class. + stdin_reader = PosixStdinReader(stdin.fileno()) + + if self.closed: + raise Exception('Event loop already closed.') + + inputstream = InputStream(callbacks.feed_key) + + try: + # Create a new Future every time. + self._stopped_f = asyncio.Future(loop=self.loop) + + # Handle input timouts + def timeout_handler(): + """ + When no input has been received for INPUT_TIMEOUT seconds, + flush the input stream and fire the timeout event. + """ + inputstream.flush() + + callbacks.input_timeout() + + timeout = AsyncioTimeout(INPUT_TIMEOUT, timeout_handler, self.loop) + + # Catch sigwinch + def received_winch(): + self.call_from_executor(callbacks.terminal_size_changed) + + self.loop.add_signal_handler(signal.SIGWINCH, received_winch) + + # Read input data. + def stdin_ready(): + data = stdin_reader.read() + inputstream.feed(data) + timeout.reset() + + # Quit when the input stream was closed. + if stdin_reader.closed: + self.stop() + + self.loop.add_reader(stdin.fileno(), stdin_ready) + + # Block this coroutine until stop() has been called. + for f in self._stopped_f: + yield f + + finally: + # Clean up. + self.loop.remove_reader(stdin.fileno()) + self.loop.remove_signal_handler(signal.SIGWINCH) + + # Don't trigger any timeout events anymore. + timeout.stop() + + def stop(self): + # Trigger the 'Stop' future. + self._stopped_f.set_result(True) + + def close(self): + # Note: we should not close the asyncio loop itself, because that one + # was not created here. + self.closed = True + + def run_in_executor(self, callback): + self.loop.run_in_executor(None, callback) + + def call_from_executor(self, callback, _max_postpone_until=None): + """ + Call this function in the main event loop. + Similar to Twisted's ``callFromThread``. + """ + self.loop.call_soon_threadsafe(callback) + + def add_reader(self, fd, callback): + " Start watching the file descriptor for read availability. " + self.loop.add_reader(fd, callback) + + def remove_reader(self, fd): + " Stop watching the file descriptor for read availability. " + self.loop.remove_reader(fd) diff --git a/src/libs/prompt_toolkit/eventloop/asyncio_win32.py b/src/libs/prompt_toolkit/eventloop/asyncio_win32.py new file mode 100644 index 0000000..45f5f52 --- /dev/null +++ b/src/libs/prompt_toolkit/eventloop/asyncio_win32.py @@ -0,0 +1,83 @@ +""" +Win32 asyncio event loop. + +Windows notes: +- Somehow it doesn't seem to work with the 'ProactorEventLoop'. +""" +from __future__ import unicode_literals + +from .base import EventLoop, INPUT_TIMEOUT +from ..terminal.win32_input import ConsoleInputReader +from .callbacks import EventLoopCallbacks +from .asyncio_base import AsyncioTimeout + +import asyncio + +__all__ = ( + 'Win32AsyncioEventLoop', +) + + +class Win32AsyncioEventLoop(EventLoop): + def __init__(self, loop=None): + self._console_input_reader = ConsoleInputReader() + self.running = False + self.closed = False + self.loop = loop or asyncio.get_event_loop() + + @asyncio.coroutine + def run_as_coroutine(self, stdin, callbacks): + """ + The input 'event loop'. + """ + # Note: We cannot use "yield from", because this package also + # installs on Python 2. + assert isinstance(callbacks, EventLoopCallbacks) + + if self.closed: + raise Exception('Event loop already closed.') + + timeout = AsyncioTimeout(INPUT_TIMEOUT, callbacks.input_timeout, self.loop) + self.running = True + + try: + while self.running: + timeout.reset() + + # Get keys + try: + g = iter(self.loop.run_in_executor(None, self._console_input_reader.read)) + while True: + yield next(g) + except StopIteration as e: + keys = e.args[0] + + # Feed keys to input processor. + for k in keys: + callbacks.feed_key(k) + finally: + timeout.stop() + + def stop(self): + self.running = False + + def close(self): + # Note: we should not close the asyncio loop itself, because that one + # was not created here. + self.closed = True + + self._console_input_reader.close() + + def run_in_executor(self, callback): + self.loop.run_in_executor(None, callback) + + def call_from_executor(self, callback, _max_postpone_until=None): + self.loop.call_soon_threadsafe(callback) + + def add_reader(self, fd, callback): + " Start watching the file descriptor for read availability. " + self.loop.add_reader(fd, callback) + + def remove_reader(self, fd): + " Stop watching the file descriptor for read availability. " + self.loop.remove_reader(fd) diff --git a/src/libs/prompt_toolkit/eventloop/base.py b/src/libs/prompt_toolkit/eventloop/base.py new file mode 100644 index 0000000..b851a21 --- /dev/null +++ b/src/libs/prompt_toolkit/eventloop/base.py @@ -0,0 +1,85 @@ +from __future__ import unicode_literals +from abc import ABCMeta, abstractmethod +from six import with_metaclass + +__all__ = ( + 'EventLoop', + 'INPUT_TIMEOUT', +) + + +#: When to trigger the `onInputTimeout` event. +INPUT_TIMEOUT = .5 + + +class EventLoop(with_metaclass(ABCMeta, object)): + """ + Eventloop interface. + """ + def run(self, stdin, callbacks): + """ + Run the eventloop until stop() is called. Report all + input/timeout/terminal-resize events to the callbacks. + + :param stdin: :class:`~libs.prompt_toolkit.input.Input` instance. + :param callbacks: :class:`~libs.prompt_toolkit.eventloop.callbacks.EventLoopCallbacks` instance. + """ + raise NotImplementedError("This eventloop doesn't implement synchronous 'run()'.") + + def run_as_coroutine(self, stdin, callbacks): + """ + Similar to `run`, but this is a coroutine. (For asyncio integration.) + """ + raise NotImplementedError("This eventloop doesn't implement 'run_as_coroutine()'.") + + @abstractmethod + def stop(self): + """ + Stop the `run` call. (Normally called by + :class:`~libs.prompt_toolkit.interface.CommandLineInterface`, when a result + is available, or Abort/Quit has been called.) + """ + + @abstractmethod + def close(self): + """ + Clean up of resources. Eventloop cannot be reused a second time after + this call. + """ + + @abstractmethod + def add_reader(self, fd, callback): + """ + Start watching the file descriptor for read availability and then call + the callback. + """ + + @abstractmethod + def remove_reader(self, fd): + """ + Stop watching the file descriptor for read availability. + """ + + @abstractmethod + def run_in_executor(self, callback): + """ + Run a long running function in a background thread. (This is + recommended for code that could block the event loop.) + Similar to Twisted's ``deferToThread``. + """ + + @abstractmethod + def call_from_executor(self, callback, _max_postpone_until=None): + """ + Call this function in the main event loop. Similar to Twisted's + ``callFromThread``. + + :param _max_postpone_until: `None` or `time.time` value. For interal + use. If the eventloop is saturated, consider this task to be low + priority and postpone maximum until this timestamp. (For instance, + repaint is done using low priority.) + + Note: In the past, this used to be a datetime.datetime instance, + but apparently, executing `time.time` is more efficient: it + does fewer system calls. (It doesn't read /etc/localtime.) + """ diff --git a/src/libs/prompt_toolkit/eventloop/callbacks.py b/src/libs/prompt_toolkit/eventloop/callbacks.py new file mode 100644 index 0000000..f96fee2 --- /dev/null +++ b/src/libs/prompt_toolkit/eventloop/callbacks.py @@ -0,0 +1,29 @@ +from __future__ import unicode_literals +from abc import ABCMeta, abstractmethod +from six import with_metaclass + +__all__ = ( + 'EventLoopCallbacks', +) + + +class EventLoopCallbacks(with_metaclass(ABCMeta, object)): + """ + This is the glue between the :class:`~libs.prompt_toolkit.eventloop.base.EventLoop` + and :class:`~libs.prompt_toolkit.interface.CommandLineInterface`. + + :meth:`~libs.prompt_toolkit.eventloop.base.EventLoop.run` takes an + :class:`.EventLoopCallbacks` instance and operates on that one, driving the + interface. + """ + @abstractmethod + def terminal_size_changed(self): + pass + + @abstractmethod + def input_timeout(self): + pass + + @abstractmethod + def feed_key(self, key): + pass diff --git a/src/libs/prompt_toolkit/eventloop/inputhook.py b/src/libs/prompt_toolkit/eventloop/inputhook.py new file mode 100644 index 0000000..ca58ff8 --- /dev/null +++ b/src/libs/prompt_toolkit/eventloop/inputhook.py @@ -0,0 +1,107 @@ +""" +Similar to `PyOS_InputHook` of the Python API. Some eventloops can have an +inputhook to allow easy integration with other event loops. + +When the eventloop of prompt-toolkit is idle, it can call such a hook. This +hook can call another eventloop that runs for a short while, for instance to +keep a graphical user interface responsive. + +It's the responsibility of this hook to exit when there is input ready. +There are two ways to detect when input is ready: + +- Call the `input_is_ready` method periodically. Quit when this returns `True`. + +- Add the `fileno` as a watch to the external eventloop. Quit when file descriptor + becomes readable. (But don't read from it.) + + Note that this is not the same as checking for `sys.stdin.fileno()`. The + eventloop of prompt-toolkit allows thread-based executors, for example for + asynchronous autocompletion. When the completion for instance is ready, we + also want prompt-toolkit to gain control again in order to display that. + +An alternative to using input hooks, is to create a custom `EventLoop` class that +controls everything. +""" +from __future__ import unicode_literals +import os +import threading +from libs.prompt_toolkit.utils import is_windows +from .select import select_fds + +__all__ = ( + 'InputHookContext', +) + + +class InputHookContext(object): + """ + Given as a parameter to the inputhook. + """ + def __init__(self, inputhook): + assert callable(inputhook) + + self.inputhook = inputhook + self._input_is_ready = None + + self._r, self._w = os.pipe() + + def input_is_ready(self): + """ + Return True when the input is ready. + """ + return self._input_is_ready(wait=False) + + def fileno(self): + """ + File descriptor that will become ready when the event loop needs to go on. + """ + return self._r + + def call_inputhook(self, input_is_ready_func): + """ + Call the inputhook. (Called by a prompt-toolkit eventloop.) + """ + self._input_is_ready = input_is_ready_func + + # Start thread that activates this pipe when there is input to process. + def thread(): + input_is_ready_func(wait=True) + os.write(self._w, b'x') + + threading.Thread(target=thread).start() + + # Call inputhook. + self.inputhook(self) + + # Flush the read end of the pipe. + try: + # Before calling 'os.read', call select.select. This is required + # when the gevent monkey patch has been applied. 'os.read' is never + # monkey patched and won't be cooperative, so that would block all + # other select() calls otherwise. + # See: http://www.gevent.org/gevent.os.html + + # Note: On Windows, this is apparently not an issue. + # However, if we would ever want to add a select call, it + # should use `windll.kernel32.WaitForMultipleObjects`, + # because `select.select` can't wait for a pipe on Windows. + if not is_windows(): + select_fds([self._r], timeout=None) + + os.read(self._r, 1024) + except OSError: + # This happens when the window resizes and a SIGWINCH was received. + # We get 'Error: [Errno 4] Interrupted system call' + # Just ignore. + pass + self._input_is_ready = None + + def close(self): + """ + Clean up resources. + """ + if self._r: + os.close(self._r) + os.close(self._w) + + self._r = self._w = None diff --git a/src/libs/prompt_toolkit/eventloop/posix.py b/src/libs/prompt_toolkit/eventloop/posix.py new file mode 100644 index 0000000..ac27bed --- /dev/null +++ b/src/libs/prompt_toolkit/eventloop/posix.py @@ -0,0 +1,311 @@ +from __future__ import unicode_literals +import fcntl +import os +import random +import signal +import threading +import time + +from libs.prompt_toolkit.terminal.vt100_input import InputStream +from libs.prompt_toolkit.utils import DummyContext, in_main_thread +from libs.prompt_toolkit.input import Input +from .base import EventLoop, INPUT_TIMEOUT +from .callbacks import EventLoopCallbacks +from .inputhook import InputHookContext +from .posix_utils import PosixStdinReader +from .utils import TimeIt +from .select import AutoSelector, Selector, fd_to_int + +__all__ = ( + 'PosixEventLoop', +) + +_now = time.time + + +class PosixEventLoop(EventLoop): + """ + Event loop for posix systems (Linux, Mac os X). + """ + def __init__(self, inputhook=None, selector=AutoSelector): + assert inputhook is None or callable(inputhook) + assert issubclass(selector, Selector) + + self.running = False + self.closed = False + self._running = False + self._callbacks = None + + self._calls_from_executor = [] + self._read_fds = {} # Maps fd to handler. + self.selector = selector() + + # Create a pipe for inter thread communication. + self._schedule_pipe = os.pipe() + fcntl.fcntl(self._schedule_pipe[0], fcntl.F_SETFL, os.O_NONBLOCK) + + # Create inputhook context. + self._inputhook_context = InputHookContext(inputhook) if inputhook else None + + def run(self, stdin, callbacks): + """ + The input 'event loop'. + """ + assert isinstance(stdin, Input) + assert isinstance(callbacks, EventLoopCallbacks) + assert not self._running + + if self.closed: + raise Exception('Event loop already closed.') + + self._running = True + self._callbacks = callbacks + + inputstream = InputStream(callbacks.feed_key) + current_timeout = [INPUT_TIMEOUT] # Nonlocal + + # Create reader class. + stdin_reader = PosixStdinReader(stdin.fileno()) + + # Only attach SIGWINCH signal handler in main thread. + # (It's not possible to attach signal handlers in other threads. In + # that case we should rely on a the main thread to call this manually + # instead.) + if in_main_thread(): + ctx = call_on_sigwinch(self.received_winch) + else: + ctx = DummyContext() + + def read_from_stdin(): + " Read user input. " + # Feed input text. + data = stdin_reader.read() + inputstream.feed(data) + + # Set timeout again. + current_timeout[0] = INPUT_TIMEOUT + + # Quit when the input stream was closed. + if stdin_reader.closed: + self.stop() + + self.add_reader(stdin, read_from_stdin) + self.add_reader(self._schedule_pipe[0], None) + + with ctx: + while self._running: + # Call inputhook. + if self._inputhook_context: + with TimeIt() as inputhook_timer: + def ready(wait): + " True when there is input ready. The inputhook should return control. " + return self._ready_for_reading(current_timeout[0] if wait else 0) != [] + self._inputhook_context.call_inputhook(ready) + inputhook_duration = inputhook_timer.duration + else: + inputhook_duration = 0 + + # Calculate remaining timeout. (The inputhook consumed some of the time.) + if current_timeout[0] is None: + remaining_timeout = None + else: + remaining_timeout = max(0, current_timeout[0] - inputhook_duration) + + # Wait until input is ready. + fds = self._ready_for_reading(remaining_timeout) + + # When any of the FDs are ready. Call the appropriate callback. + if fds: + # Create lists of high/low priority tasks. The main reason + # for this is to allow painting the UI to happen as soon as + # possible, but when there are many events happening, we + # don't want to call the UI renderer 1000x per second. If + # the eventloop is completely saturated with many CPU + # intensive tasks (like processing input/output), we say + # that drawing the UI can be postponed a little, to make + # CPU available. This will be a low priority task in that + # case. + tasks = [] + low_priority_tasks = [] + now = None # Lazy load time. (Fewer system calls.) + + for fd in fds: + # For the 'call_from_executor' fd, put each pending + # item on either the high or low priority queue. + if fd == self._schedule_pipe[0]: + for c, max_postpone_until in self._calls_from_executor: + if max_postpone_until is None: + # Execute now. + tasks.append(c) + else: + # Execute soon, if `max_postpone_until` is in the future. + now = now or _now() + if max_postpone_until < now: + tasks.append(c) + else: + low_priority_tasks.append((c, max_postpone_until)) + self._calls_from_executor = [] + + # Flush all the pipe content. + os.read(self._schedule_pipe[0], 1024) + else: + handler = self._read_fds.get(fd) + if handler: + tasks.append(handler) + + # Handle everything in random order. (To avoid starvation.) + random.shuffle(tasks) + random.shuffle(low_priority_tasks) + + # When there are high priority tasks, run all these. + # Schedule low priority tasks for the next iteration. + if tasks: + for t in tasks: + t() + + # Postpone low priority tasks. + for t, max_postpone_until in low_priority_tasks: + self.call_from_executor(t, _max_postpone_until=max_postpone_until) + else: + # Currently there are only low priority tasks -> run them right now. + for t, _ in low_priority_tasks: + t() + + else: + # Flush all pending keys on a timeout. (This is most + # important to flush the vt100 'Escape' key early when + # nothing else follows.) + inputstream.flush() + + # Fire input timeout event. + callbacks.input_timeout() + current_timeout[0] = None + + self.remove_reader(stdin) + self.remove_reader(self._schedule_pipe[0]) + + self._callbacks = None + + def _ready_for_reading(self, timeout=None): + """ + Return the file descriptors that are ready for reading. + """ + fds = self.selector.select(timeout) + return fds + + def received_winch(self): + """ + Notify the event loop that SIGWINCH has been received + """ + # Process signal asynchronously, because this handler can write to the + # output, and doing this inside the signal handler causes easily + # reentrant calls, giving runtime errors.. + + # Furthur, this has to be thread safe. When the CommandLineInterface + # runs not in the main thread, this function still has to be called + # from the main thread. (The only place where we can install signal + # handlers.) + def process_winch(): + if self._callbacks: + self._callbacks.terminal_size_changed() + + self.call_from_executor(process_winch) + + def run_in_executor(self, callback): + """ + Run a long running function in a background thread. + (This is recommended for code that could block the event loop.) + Similar to Twisted's ``deferToThread``. + """ + # Wait until the main thread is idle. + # We start the thread by using `call_from_executor`. The event loop + # favours processing input over `calls_from_executor`, so the thread + # will not start until there is no more input to process and the main + # thread becomes idle for an instant. This is good, because Python + # threading favours CPU over I/O -- an autocompletion thread in the + # background would cause a significantly slow down of the main thread. + # It is mostly noticable when pasting large portions of text while + # having real time autocompletion while typing on. + def start_executor(): + threading.Thread(target=callback).start() + self.call_from_executor(start_executor) + + def call_from_executor(self, callback, _max_postpone_until=None): + """ + Call this function in the main event loop. + Similar to Twisted's ``callFromThread``. + + :param _max_postpone_until: `None` or `time.time` value. For interal + use. If the eventloop is saturated, consider this task to be low + priority and postpone maximum until this timestamp. (For instance, + repaint is done using low priority.) + """ + assert _max_postpone_until is None or isinstance(_max_postpone_until, float) + self._calls_from_executor.append((callback, _max_postpone_until)) + + if self._schedule_pipe: + try: + os.write(self._schedule_pipe[1], b'x') + except (AttributeError, IndexError, OSError): + # Handle race condition. We're in a different thread. + # - `_schedule_pipe` could have become None in the meantime. + # - We catch `OSError` (actually BrokenPipeError), because the + # main thread could have closed the pipe already. + pass + + def stop(self): + """ + Stop the event loop. + """ + self._running = False + + def close(self): + self.closed = True + + # Close pipes. + schedule_pipe = self._schedule_pipe + self._schedule_pipe = None + + if schedule_pipe: + os.close(schedule_pipe[0]) + os.close(schedule_pipe[1]) + + if self._inputhook_context: + self._inputhook_context.close() + + def add_reader(self, fd, callback): + " Add read file descriptor to the event loop. " + fd = fd_to_int(fd) + self._read_fds[fd] = callback + self.selector.register(fd) + + def remove_reader(self, fd): + " Remove read file descriptor from the event loop. " + fd = fd_to_int(fd) + + if fd in self._read_fds: + del self._read_fds[fd] + + self.selector.unregister(fd) + + +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 + self.previous_callback = None + + def __enter__(self): + self.previous_callback = signal.signal(signal.SIGWINCH, lambda *a: self.callback()) + + def __exit__(self, *a, **kw): + if self.previous_callback is None: + # Normally, `signal.signal` should never return `None`. + # For some reason it happens here: + # https://github.com/jonathanslenders/python-prompt-toolkit/pull/174 + signal.signal(signal.SIGWINCH, 0) + else: + signal.signal(signal.SIGWINCH, self.previous_callback) diff --git a/src/libs/prompt_toolkit/eventloop/posix_utils.py b/src/libs/prompt_toolkit/eventloop/posix_utils.py new file mode 100644 index 0000000..320df43 --- /dev/null +++ b/src/libs/prompt_toolkit/eventloop/posix_utils.py @@ -0,0 +1,82 @@ +from __future__ import unicode_literals + +from codecs import getincrementaldecoder +import os +import six + +__all__ = ( + 'PosixStdinReader', +) + + +class PosixStdinReader(object): + """ + Wrapper around stdin which reads (nonblocking) the next available 1024 + bytes and decodes it. + + Note that you can't be sure that the input file is closed if the ``read`` + function returns an empty string. When ``errors=ignore`` is passed, + ``read`` can return an empty string if all malformed input was replaced by + an empty string. (We can't block here and wait for more input.) So, because + of that, check the ``closed`` attribute, to be sure that the file has been + closed. + + :param stdin_fd: File descriptor from which we read. + :param errors: Can be 'ignore', 'strict' or 'replace'. + On Python3, this can be 'surrogateescape', which is the default. + + 'surrogateescape' is preferred, because this allows us to transfer + unrecognised bytes to the key bindings. Some terminals, like lxterminal + and Guake, use the 'Mxx' notation to send mouse events, where each 'x' + can be any possible byte. + """ + # By default, we want to 'ignore' errors here. The input stream can be full + # of junk. One occurrence of this that I had was when using iTerm2 on OS X, + # with "Option as Meta" checked (You should choose "Option as +Esc".) + + def __init__(self, stdin_fd, + errors=('ignore' if six.PY2 else 'surrogateescape')): + assert isinstance(stdin_fd, int) + self.stdin_fd = stdin_fd + self.errors = errors + + # Create incremental decoder for decoding stdin. + # We can not just do `os.read(stdin.fileno(), 1024).decode('utf-8')`, because + # it could be that we are in the middle of a utf-8 byte sequence. + self._stdin_decoder_cls = getincrementaldecoder('utf-8') + self._stdin_decoder = self._stdin_decoder_cls(errors=errors) + + #: True when there is nothing anymore to read. + self.closed = False + + def read(self, count=1024): + # By default we choose a rather small chunk size, because reading + # big amounts of input at once, causes the event loop to process + # all these key bindings also at once without going back to the + # loop. This will make the application feel unresponsive. + """ + Read the input and return it as a string. + + Return the text. Note that this can return an empty string, even when + the input stream was not yet closed. This means that something went + wrong during the decoding. + """ + if self.closed: + return b'' + + # Note: the following works better than wrapping `self.stdin` like + # `codecs.getreader('utf-8')(stdin)` and doing `read(1)`. + # Somehow that causes some latency when the escape + # character is pressed. (Especially on combination with the `select`.) + try: + data = os.read(self.stdin_fd, count) + + # Nothing more to read, stream is closed. + if data == b'': + self.closed = True + return '' + except OSError: + # In case of SIGWINCH + data = b'' + + return self._stdin_decoder.decode(data) diff --git a/src/libs/prompt_toolkit/eventloop/select.py b/src/libs/prompt_toolkit/eventloop/select.py new file mode 100644 index 0000000..f678f84 --- /dev/null +++ b/src/libs/prompt_toolkit/eventloop/select.py @@ -0,0 +1,216 @@ +""" +Selectors for the Posix event loop. +""" +from __future__ import unicode_literals, absolute_import +import sys +import abc +import errno +import select +import six + +__all__ = ( + 'AutoSelector', + 'PollSelector', + 'SelectSelector', + 'Selector', + 'fd_to_int', +) + +def fd_to_int(fd): + assert isinstance(fd, int) or hasattr(fd, 'fileno') + + if isinstance(fd, int): + return fd + else: + return fd.fileno() + + +class Selector(six.with_metaclass(abc.ABCMeta, object)): + @abc.abstractmethod + def register(self, fd): + assert isinstance(fd, int) + + @abc.abstractmethod + def unregister(self, fd): + assert isinstance(fd, int) + + @abc.abstractmethod + def select(self, timeout): + pass + + @abc.abstractmethod + def close(self): + pass + + +class AutoSelector(Selector): + def __init__(self): + self._fds = [] + + self._select_selector = SelectSelector() + self._selectors = [self._select_selector] + + # When 'select.poll' exists, create a PollSelector. + if hasattr(select, 'poll'): + self._poll_selector = PollSelector() + self._selectors.append(self._poll_selector) + else: + self._poll_selector = None + + # Use of the 'select' module, that was introduced in Python3.4. We don't + # use it before 3.5 however, because this is the point where this module + # retries interrupted system calls. + if sys.version_info >= (3, 5): + self._py3_selector = Python3Selector() + self._selectors.append(self._py3_selector) + else: + self._py3_selector = None + + def register(self, fd): + assert isinstance(fd, int) + + self._fds.append(fd) + + for sel in self._selectors: + sel.register(fd) + + def unregister(self, fd): + assert isinstance(fd, int) + + self._fds.remove(fd) + + for sel in self._selectors: + sel.unregister(fd) + + def select(self, timeout): + # Try Python 3 selector first. + if self._py3_selector: + try: + return self._py3_selector.select(timeout) + except PermissionError: + # We had a situation (in pypager) where epoll raised a + # PermissionError when a local file descriptor was registered, + # however poll and select worked fine. So, in that case, just + # try using select below. + pass + + try: + # Prefer 'select.select', if we don't have much file descriptors. + # This is more universal. + return self._select_selector.select(timeout) + except ValueError: + # When we have more than 1024 open file descriptors, we'll always + # get a "ValueError: filedescriptor out of range in select()" for + # 'select'. In this case, try, using 'poll' instead. + if self._poll_selector is not None: + return self._poll_selector.select(timeout) + else: + raise + + def close(self): + for sel in self._selectors: + sel.close() + + +class Python3Selector(Selector): + """ + Use of the Python3 'selectors' module. + + NOTE: Only use on Python 3.5 or newer! + """ + def __init__(self): + assert sys.version_info >= (3, 5) + + import selectors # Inline import: Python3 only! + self._sel = selectors.DefaultSelector() + + def register(self, fd): + assert isinstance(fd, int) + import selectors # Inline import: Python3 only! + self._sel.register(fd, selectors.EVENT_READ, None) + + def unregister(self, fd): + assert isinstance(fd, int) + self._sel.unregister(fd) + + def select(self, timeout): + events = self._sel.select(timeout=timeout) + return [key.fileobj for key, mask in events] + + def close(self): + self._sel.close() + + +class PollSelector(Selector): + def __init__(self): + self._poll = select.poll() + + def register(self, fd): + assert isinstance(fd, int) + self._poll.register(fd, select.POLLIN) + + def unregister(self, fd): + assert isinstance(fd, int) + + def select(self, timeout): + tuples = self._poll.poll(timeout) # Returns (fd, event) tuples. + return [t[0] for t in tuples] + + def close(self): + pass # XXX + + +class SelectSelector(Selector): + """ + 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. + """ + def __init__(self): + self._fds = [] + + def register(self, fd): + self._fds.append(fd) + + def unregister(self, fd): + self._fds.remove(fd) + + def select(self, timeout): + while True: + try: + return select.select(self._fds, [], [], timeout)[0] + except select.error as e: + # Retry select call when EINTR + if e.args and e.args[0] == errno.EINTR: + continue + else: + raise + + def close(self): + pass + + +def select_fds(read_fds, timeout, selector=AutoSelector): + """ + Wait for a list of file descriptors (`read_fds`) to become ready for + reading. This chooses the most appropriate select-tool for use in + prompt-toolkit. + """ + # Map to ensure that we return the objects that were passed in originally. + # Whether they are a fd integer or an object that has a fileno(). + # (The 'poll' implementation for instance, returns always integers.) + fd_map = dict((fd_to_int(fd), fd) for fd in read_fds) + + # Wait, using selector. + sel = selector() + try: + for fd in read_fds: + sel.register(fd) + + result = sel.select(timeout) + + if result is not None: + return [fd_map[fd_to_int(fd)] for fd in result] + finally: + sel.close() diff --git a/src/libs/prompt_toolkit/eventloop/utils.py b/src/libs/prompt_toolkit/eventloop/utils.py new file mode 100644 index 0000000..ff3a4cf --- /dev/null +++ b/src/libs/prompt_toolkit/eventloop/utils.py @@ -0,0 +1,23 @@ +from __future__ import unicode_literals +import time + +__all__ = ( + 'TimeIt', +) + + +class TimeIt(object): + """ + Context manager that times the duration of the code body. + The `duration` attribute will contain the execution time in seconds. + """ + def __init__(self): + self.duration = None + + def __enter__(self): + self.start = time.time() + return self + + def __exit__(self, *args): + self.end = time.time() + self.duration = self.end - self.start diff --git a/src/libs/prompt_toolkit/eventloop/win32.py b/src/libs/prompt_toolkit/eventloop/win32.py new file mode 100644 index 0000000..9d4d642 --- /dev/null +++ b/src/libs/prompt_toolkit/eventloop/win32.py @@ -0,0 +1,187 @@ +""" +Win32 event loop. + +Windows notes: + - Somehow it doesn't seem to work with the 'ProactorEventLoop'. +""" +from __future__ import unicode_literals + +from ..terminal.win32_input import ConsoleInputReader +from ..win32_types import SECURITY_ATTRIBUTES +from .base import EventLoop, INPUT_TIMEOUT +from .inputhook import InputHookContext +from .utils import TimeIt + +from ctypes import windll, pointer +from ctypes.wintypes import DWORD, BOOL, HANDLE + +import msvcrt +import threading + +__all__ = ( + 'Win32EventLoop', +) + +WAIT_TIMEOUT = 0x00000102 +INPUT_TIMEOUT_MS = int(1000 * INPUT_TIMEOUT) + + +class Win32EventLoop(EventLoop): + """ + Event loop for Windows systems. + + :param recognize_paste: When True, try to discover paste actions and turn + the event into a BracketedPaste. + """ + def __init__(self, inputhook=None, recognize_paste=True): + assert inputhook is None or callable(inputhook) + + self._event = _create_event() + self._console_input_reader = ConsoleInputReader(recognize_paste=recognize_paste) + self._calls_from_executor = [] + + self.closed = False + self._running = False + + # Additional readers. + self._read_fds = {} # Maps fd to handler. + + # Create inputhook context. + self._inputhook_context = InputHookContext(inputhook) if inputhook else None + + def run(self, stdin, callbacks): + if self.closed: + raise Exception('Event loop already closed.') + + current_timeout = INPUT_TIMEOUT_MS + self._running = True + + while self._running: + # Call inputhook. + with TimeIt() as inputhook_timer: + if self._inputhook_context: + def ready(wait): + " True when there is input ready. The inputhook should return control. " + return bool(self._ready_for_reading(current_timeout if wait else 0)) + self._inputhook_context.call_inputhook(ready) + + # Calculate remaining timeout. (The inputhook consumed some of the time.) + if current_timeout == -1: + remaining_timeout = -1 + else: + remaining_timeout = max(0, current_timeout - int(1000 * inputhook_timer.duration)) + + # Wait for the next event. + handle = self._ready_for_reading(remaining_timeout) + + if handle == self._console_input_reader.handle: + # When stdin is ready, read input and reset timeout timer. + keys = self._console_input_reader.read() + for k in keys: + callbacks.feed_key(k) + current_timeout = INPUT_TIMEOUT_MS + + elif handle == self._event: + # When the Windows Event has been trigger, process the messages in the queue. + windll.kernel32.ResetEvent(self._event) + self._process_queued_calls_from_executor() + + elif handle in self._read_fds: + callback = self._read_fds[handle] + callback() + else: + # Fire input timeout event. + callbacks.input_timeout() + current_timeout = -1 + + def _ready_for_reading(self, timeout=None): + """ + Return the handle that is ready for reading or `None` on timeout. + """ + handles = [self._event, self._console_input_reader.handle] + handles.extend(self._read_fds.keys()) + return _wait_for_handles(handles, timeout) + + def stop(self): + self._running = False + + def close(self): + self.closed = True + + # Clean up Event object. + windll.kernel32.CloseHandle(self._event) + + if self._inputhook_context: + self._inputhook_context.close() + + self._console_input_reader.close() + + def run_in_executor(self, callback): + """ + Run a long running function in a background thread. + (This is recommended for code that could block the event loop.) + Similar to Twisted's ``deferToThread``. + """ + # Wait until the main thread is idle for an instant before starting the + # executor. (Like in eventloop/posix.py, we start the executor using + # `call_from_executor`.) + def start_executor(): + threading.Thread(target=callback).start() + self.call_from_executor(start_executor) + + def call_from_executor(self, callback, _max_postpone_until=None): + """ + Call this function in the main event loop. + Similar to Twisted's ``callFromThread``. + """ + # Append to list of pending callbacks. + self._calls_from_executor.append(callback) + + # Set Windows event. + windll.kernel32.SetEvent(self._event) + + def _process_queued_calls_from_executor(self): + # Process calls from executor. + calls_from_executor, self._calls_from_executor = self._calls_from_executor, [] + for c in calls_from_executor: + c() + + def add_reader(self, fd, callback): + " Start watching the file descriptor for read availability. " + h = msvcrt.get_osfhandle(fd) + self._read_fds[h] = callback + + def remove_reader(self, fd): + " Stop watching the file descriptor for read availability. " + h = msvcrt.get_osfhandle(fd) + if h in self._read_fds: + del self._read_fds[h] + + +def _wait_for_handles(handles, timeout=-1): + """ + Waits for multiple handles. (Similar to 'select') Returns the handle which is ready. + Returns `None` on timeout. + + http://msdn.microsoft.com/en-us/library/windows/desktop/ms687025(v=vs.85).aspx + """ + arrtype = HANDLE * len(handles) + handle_array = arrtype(*handles) + + ret = windll.kernel32.WaitForMultipleObjects( + len(handle_array), handle_array, BOOL(False), DWORD(timeout)) + + if ret == WAIT_TIMEOUT: + return None + else: + h = handle_array[ret] + return h + + +def _create_event(): + """ + Creates a Win32 unnamed Event . + + http://msdn.microsoft.com/en-us/library/windows/desktop/ms682396(v=vs.85).aspx + """ + return windll.kernel32.CreateEventA(pointer(SECURITY_ATTRIBUTES()), BOOL(True), BOOL(False), None) diff --git a/src/libs/prompt_toolkit/filters/__init__.py b/src/libs/prompt_toolkit/filters/__init__.py new file mode 100644 index 0000000..d3f14ef --- /dev/null +++ b/src/libs/prompt_toolkit/filters/__init__.py @@ -0,0 +1,36 @@ +""" +Filters decide whether something is active or not (they decide about a boolean +state). This is used to enable/disable features, like key bindings, parts of +the layout and other stuff. For instance, we could have a `HasSearch` filter +attached to some part of the layout, in order to show that part of the user +interface only while the user is searching. + +Filters are made to avoid having to attach callbacks to all event in order to +propagate state. However, they are lazy, they don't automatically propagate the +state of what they are observing. Only when a filter is called (it's actually a +callable), it will calculate its value. So, its not really reactive +programming, but it's made to fit for this framework. + +One class of filters observe a `CommandLineInterface` instance. However, they +are not attached to such an instance. (We have to pass this instance to the +filter when calling it.) The reason for this is to allow declarative +programming: for key bindings, we can attach a filter to a key binding without +knowing yet which `CommandLineInterface` instance it will observe in the end. +Examples are `HasSearch` or `IsExiting`. + +Another class of filters doesn't take anything as input. And a third class of +filters are universal, for instance `Always` and `Never`. +It is impossible to mix the first and the second class, because that would mean +mixing filters with a different signature. + +Filters can be chained using ``&`` and ``|`` operations, and inverted using the +``~`` operator, for instance:: + + filter = HasFocus('default') & ~ HasSelection() +""" +from __future__ import unicode_literals + +from .base import * +from .cli import * +from .types import * +from .utils import * diff --git a/src/libs/prompt_toolkit/filters/base.py b/src/libs/prompt_toolkit/filters/base.py new file mode 100644 index 0000000..578617b --- /dev/null +++ b/src/libs/prompt_toolkit/filters/base.py @@ -0,0 +1,234 @@ +from __future__ import unicode_literals +from abc import ABCMeta, abstractmethod +from six import with_metaclass + +from libs.prompt_toolkit.utils import test_callable_args + + +__all__ = ( + 'Filter', + 'Never', + 'Always', + 'Condition', +) + + +class Filter(with_metaclass(ABCMeta, object)): + """ + Filter to activate/deactivate a feature, depending on a condition. + The return value of ``__call__`` will tell if the feature should be active. + """ + @abstractmethod + def __call__(self, *a, **kw): + """ + The actual call to evaluate the filter. + """ + return True + + def __and__(self, other): + """ + Chaining of filters using the & operator. + """ + return _and_cache[self, other] + + def __or__(self, other): + """ + Chaining of filters using the | operator. + """ + return _or_cache[self, other] + + def __invert__(self): + """ + Inverting of filters using the ~ operator. + """ + return _invert_cache[self] + + def __bool__(self): + """ + By purpose, we don't allow bool(...) operations directly on a filter, + because because the meaning is ambigue. + + Executing a filter has to be done always by calling it. Providing + defaults for `None` values should be done through an `is None` check + instead of for instance ``filter1 or Always()``. + """ + raise TypeError + + __nonzero__ = __bool__ # For Python 2. + + def test_args(self, *args): + """ + Test whether this filter can be called with the following argument list. + """ + return test_callable_args(self.__call__, args) + + +class _AndCache(dict): + """ + Cache for And operation between filters. + (Filter classes are stateless, so we can reuse them.) + + Note: This could be a memory leak if we keep creating filters at runtime. + If that is True, the filters should be weakreffed (not the tuple of + filters), and tuples should be removed when one of these filters is + removed. In practise however, there is a finite amount of filters. + """ + def __missing__(self, filters): + a, b = filters + assert isinstance(b, Filter), 'Expecting filter, got %r' % b + + if isinstance(b, Always) or isinstance(a, Never): + return a + elif isinstance(b, Never) or isinstance(a, Always): + return b + + result = _AndList(filters) + self[filters] = result + return result + + +class _OrCache(dict): + """ Cache for Or operation between filters. """ + def __missing__(self, filters): + a, b = filters + assert isinstance(b, Filter), 'Expecting filter, got %r' % b + + if isinstance(b, Always) or isinstance(a, Never): + return b + elif isinstance(b, Never) or isinstance(a, Always): + return a + + result = _OrList(filters) + self[filters] = result + return result + + +class _InvertCache(dict): + """ Cache for inversion operator. """ + def __missing__(self, filter): + result = _Invert(filter) + self[filter] = result + return result + + +_and_cache = _AndCache() +_or_cache = _OrCache() +_invert_cache = _InvertCache() + + +class _AndList(Filter): + """ + Result of &-operation between several filters. + """ + def __init__(self, filters): + all_filters = [] + + for f in filters: + if isinstance(f, _AndList): # Turn nested _AndLists into one. + all_filters.extend(f.filters) + else: + all_filters.append(f) + + self.filters = all_filters + + def test_args(self, *args): + return all(f.test_args(*args) for f in self.filters) + + def __call__(self, *a, **kw): + return all(f(*a, **kw) for f in self.filters) + + def __repr__(self): + return '&'.join(repr(f) for f in self.filters) + + +class _OrList(Filter): + """ + Result of |-operation between several filters. + """ + def __init__(self, filters): + all_filters = [] + + for f in filters: + if isinstance(f, _OrList): # Turn nested _OrLists into one. + all_filters.extend(f.filters) + else: + all_filters.append(f) + + self.filters = all_filters + + def test_args(self, *args): + return all(f.test_args(*args) for f in self.filters) + + def __call__(self, *a, **kw): + return any(f(*a, **kw) for f in self.filters) + + def __repr__(self): + return '|'.join(repr(f) for f in self.filters) + + +class _Invert(Filter): + """ + Negation of another filter. + """ + def __init__(self, filter): + self.filter = filter + + def __call__(self, *a, **kw): + return not self.filter(*a, **kw) + + def __repr__(self): + return '~%r' % self.filter + + def test_args(self, *args): + return self.filter.test_args(*args) + + +class Always(Filter): + """ + Always enable feature. + """ + def __call__(self, *a, **kw): + return True + + def __invert__(self): + return Never() + + +class Never(Filter): + """ + Never enable feature. + """ + def __call__(self, *a, **kw): + return False + + def __invert__(self): + return Always() + + +class Condition(Filter): + """ + Turn any callable (which takes a cli and returns a boolean) into a Filter. + + This can be used as a decorator:: + + @Condition + def feature_is_active(cli): # `feature_is_active` becomes a Filter. + return True + + :param func: Callable which takes either a + :class:`~prompt_toolkit.interface.CommandLineInterface` or nothing and + returns a boolean. (Depending on what it takes, this will become a + :class:`.Filter` or :class:`~prompt_toolkit.filters.CLIFilter`.) + """ + def __init__(self, func): + assert callable(func) + self.func = func + + def __call__(self, *a, **kw): + return self.func(*a, **kw) + + def __repr__(self): + return 'Condition(%r)' % self.func + + def test_args(self, *a): + return test_callable_args(self.func, a) diff --git a/src/libs/prompt_toolkit/filters/cli.py b/src/libs/prompt_toolkit/filters/cli.py new file mode 100644 index 0000000..c14c271 --- /dev/null +++ b/src/libs/prompt_toolkit/filters/cli.py @@ -0,0 +1,395 @@ +""" +Filters that accept a `CommandLineInterface` as argument. +""" +from __future__ import unicode_literals +from .base import Filter +from libs.prompt_toolkit.enums import EditingMode +from libs.prompt_toolkit.key_binding.vi_state import InputMode as ViInputMode +from libs.prompt_toolkit.cache import memoized + +__all__ = ( + 'HasArg', + 'HasCompletions', + 'HasFocus', + 'InFocusStack', + 'HasSearch', + 'HasSelection', + 'HasValidationError', + 'IsAborting', + 'IsDone', + 'IsMultiline', + 'IsReadOnly', + 'IsReturning', + 'RendererHeightIsKnown', + 'InEditingMode', + + # Vi modes. + 'ViMode', + 'ViNavigationMode', + 'ViInsertMode', + 'ViInsertMultipleMode', + 'ViReplaceMode', + 'ViSelectionMode', + 'ViWaitingForTextObjectMode', + 'ViDigraphMode', + + # Emacs modes. + 'EmacsMode', + 'EmacsInsertMode', + 'EmacsSelectionMode', +) + + +@memoized() +class HasFocus(Filter): + """ + Enable when this buffer has the focus. + """ + def __init__(self, buffer_name): + self._buffer_name = buffer_name + + @property + def buffer_name(self): + " The given buffer name. (Read-only) " + return self._buffer_name + + def __call__(self, cli): + return cli.current_buffer_name == self.buffer_name + + def __repr__(self): + return 'HasFocus(%r)' % self.buffer_name + + +@memoized() +class InFocusStack(Filter): + """ + Enable when this buffer appears on the focus stack. + """ + def __init__(self, buffer_name): + self._buffer_name = buffer_name + + @property + def buffer_name(self): + " The given buffer name. (Read-only) " + return self._buffer_name + + def __call__(self, cli): + return self.buffer_name in cli.buffers.focus_stack + + def __repr__(self): + return 'InFocusStack(%r)' % self.buffer_name + + +@memoized() +class HasSelection(Filter): + """ + Enable when the current buffer has a selection. + """ + def __call__(self, cli): + return bool(cli.current_buffer.selection_state) + + def __repr__(self): + return 'HasSelection()' + + +@memoized() +class HasCompletions(Filter): + """ + Enable when the current buffer has completions. + """ + def __call__(self, cli): + return cli.current_buffer.complete_state is not None + + def __repr__(self): + return 'HasCompletions()' + + +@memoized() +class IsMultiline(Filter): + """ + Enable in multiline mode. + """ + def __call__(self, cli): + return cli.current_buffer.is_multiline() + + def __repr__(self): + return 'IsMultiline()' + + +@memoized() +class IsReadOnly(Filter): + """ + True when the current buffer is read only. + """ + def __call__(self, cli): + return cli.current_buffer.read_only() + + def __repr__(self): + return 'IsReadOnly()' + + +@memoized() +class HasValidationError(Filter): + """ + Current buffer has validation error. + """ + def __call__(self, cli): + return cli.current_buffer.validation_error is not None + + def __repr__(self): + return 'HasValidationError()' + + +@memoized() +class HasArg(Filter): + """ + Enable when the input processor has an 'arg'. + """ + def __call__(self, cli): + return cli.input_processor.arg is not None + + def __repr__(self): + return 'HasArg()' + + +@memoized() +class HasSearch(Filter): + """ + Incremental search is active. + """ + def __call__(self, cli): + return cli.is_searching + + def __repr__(self): + return 'HasSearch()' + + +@memoized() +class IsReturning(Filter): + """ + When a return value has been set. + """ + def __call__(self, cli): + return cli.is_returning + + def __repr__(self): + return 'IsReturning()' + + +@memoized() +class IsAborting(Filter): + """ + True when aborting. (E.g. Control-C pressed.) + """ + def __call__(self, cli): + return cli.is_aborting + + def __repr__(self): + return 'IsAborting()' + + +@memoized() +class IsExiting(Filter): + """ + True when exiting. (E.g. Control-D pressed.) + """ + def __call__(self, cli): + return cli.is_exiting + + def __repr__(self): + return 'IsExiting()' + + +@memoized() +class IsDone(Filter): + """ + True when the CLI is returning, aborting or exiting. + """ + def __call__(self, cli): + return cli.is_done + + def __repr__(self): + return 'IsDone()' + + +@memoized() +class RendererHeightIsKnown(Filter): + """ + Only True when the renderer knows it's real height. + + (On VT100 terminals, we have to wait for a CPR response, before we can be + sure of the available height between the cursor position and the bottom of + the terminal. And usually it's nicer to wait with drawing bottom toolbars + until we receive the height, in order to avoid flickering -- first drawing + somewhere in the middle, and then again at the bottom.) + """ + def __call__(self, cli): + return cli.renderer.height_is_known + + def __repr__(self): + return 'RendererHeightIsKnown()' + + +@memoized() +class InEditingMode(Filter): + """ + Check whether a given editing mode is active. (Vi or Emacs.) + """ + def __init__(self, editing_mode): + self._editing_mode = editing_mode + + @property + def editing_mode(self): + " The given editing mode. (Read-only) " + return self._editing_mode + + def __call__(self, cli): + return cli.editing_mode == self.editing_mode + + def __repr__(self): + return 'InEditingMode(%r)' % (self.editing_mode, ) + + +@memoized() +class ViMode(Filter): + def __call__(self, cli): + return cli.editing_mode == EditingMode.VI + + def __repr__(self): + return 'ViMode()' + + +@memoized() +class ViNavigationMode(Filter): + """ + Active when the set for Vi navigation key bindings are active. + """ + def __call__(self, cli): + if (cli.editing_mode != EditingMode.VI + or cli.vi_state.operator_func + or cli.vi_state.waiting_for_digraph + or cli.current_buffer.selection_state): + return False + + return (cli.vi_state.input_mode == ViInputMode.NAVIGATION or + cli.current_buffer.read_only()) + + def __repr__(self): + return 'ViNavigationMode()' + + +@memoized() +class ViInsertMode(Filter): + def __call__(self, cli): + if (cli.editing_mode != EditingMode.VI + or cli.vi_state.operator_func + or cli.vi_state.waiting_for_digraph + or cli.current_buffer.selection_state + or cli.current_buffer.read_only()): + return False + + return cli.vi_state.input_mode == ViInputMode.INSERT + + def __repr__(self): + return 'ViInputMode()' + + +@memoized() +class ViInsertMultipleMode(Filter): + def __call__(self, cli): + if (cli.editing_mode != EditingMode.VI + or cli.vi_state.operator_func + or cli.vi_state.waiting_for_digraph + or cli.current_buffer.selection_state + or cli.current_buffer.read_only()): + return False + + return cli.vi_state.input_mode == ViInputMode.INSERT_MULTIPLE + + def __repr__(self): + return 'ViInsertMultipleMode()' + + +@memoized() +class ViReplaceMode(Filter): + def __call__(self, cli): + if (cli.editing_mode != EditingMode.VI + or cli.vi_state.operator_func + or cli.vi_state.waiting_for_digraph + or cli.current_buffer.selection_state + or cli.current_buffer.read_only()): + return False + + return cli.vi_state.input_mode == ViInputMode.REPLACE + + def __repr__(self): + return 'ViReplaceMode()' + + +@memoized() +class ViSelectionMode(Filter): + def __call__(self, cli): + if cli.editing_mode != EditingMode.VI: + return False + + return bool(cli.current_buffer.selection_state) + + def __repr__(self): + return 'ViSelectionMode()' + + +@memoized() +class ViWaitingForTextObjectMode(Filter): + def __call__(self, cli): + if cli.editing_mode != EditingMode.VI: + return False + + return cli.vi_state.operator_func is not None + + def __repr__(self): + return 'ViWaitingForTextObjectMode()' + + +@memoized() +class ViDigraphMode(Filter): + def __call__(self, cli): + if cli.editing_mode != EditingMode.VI: + return False + + return cli.vi_state.waiting_for_digraph + + def __repr__(self): + return 'ViDigraphMode()' + + +@memoized() +class EmacsMode(Filter): + " When the Emacs bindings are active. " + def __call__(self, cli): + return cli.editing_mode == EditingMode.EMACS + + def __repr__(self): + return 'EmacsMode()' + + +@memoized() +class EmacsInsertMode(Filter): + def __call__(self, cli): + if (cli.editing_mode != EditingMode.EMACS + or cli.current_buffer.selection_state + or cli.current_buffer.read_only()): + return False + return True + + def __repr__(self): + return 'EmacsInsertMode()' + + +@memoized() +class EmacsSelectionMode(Filter): + def __call__(self, cli): + return (cli.editing_mode == EditingMode.EMACS + and cli.current_buffer.selection_state) + + def __repr__(self): + return 'EmacsSelectionMode()' diff --git a/src/libs/prompt_toolkit/filters/types.py b/src/libs/prompt_toolkit/filters/types.py new file mode 100644 index 0000000..3e89c39 --- /dev/null +++ b/src/libs/prompt_toolkit/filters/types.py @@ -0,0 +1,55 @@ +from __future__ import unicode_literals +from six import with_metaclass +from collections import defaultdict +import weakref + +__all__ = ( + 'CLIFilter', + 'SimpleFilter', +) + +# Cache for _FilterTypeMeta. (Don't test the same __instancecheck__ twice as +# long as the object lives. -- We do this a lot and calling 'test_args' is +# expensive.) +_instance_check_cache = defaultdict(weakref.WeakKeyDictionary) + + +class _FilterTypeMeta(type): + def __instancecheck__(cls, instance): + cache = _instance_check_cache[tuple(cls.arguments_list)] + + def get(): + " The actual test. " + if not hasattr(instance, 'test_args'): + return False + return instance.test_args(*cls.arguments_list) + + try: + return cache[instance] + except KeyError: + result = get() + cache[instance] = result + return result + + +class _FilterType(with_metaclass(_FilterTypeMeta)): + def __new__(cls): + raise NotImplementedError('This class should not be initiated.') + + +class CLIFilter(_FilterType): + """ + Abstract base class for filters that accept a + :class:`~prompt_toolkit.interface.CommandLineInterface` argument. It cannot + be instantiated, it's only to be used for instance assertions, e.g.:: + + isinstance(my_filter, CliFilter) + """ + arguments_list = ['cli'] + + +class SimpleFilter(_FilterType): + """ + Abstract base class for filters that don't accept any arguments. + """ + arguments_list = [] diff --git a/src/libs/prompt_toolkit/filters/utils.py b/src/libs/prompt_toolkit/filters/utils.py new file mode 100644 index 0000000..836d295 --- /dev/null +++ b/src/libs/prompt_toolkit/filters/utils.py @@ -0,0 +1,39 @@ +from __future__ import unicode_literals +from .base import Always, Never +from .types import SimpleFilter, CLIFilter + +__all__ = ( + 'to_cli_filter', + 'to_simple_filter', +) + +_always = Always() +_never = Never() + + +def to_simple_filter(bool_or_filter): + """ + Accept both booleans and CLIFilters as input and + turn it into a SimpleFilter. + """ + if not isinstance(bool_or_filter, (bool, SimpleFilter)): + raise TypeError('Expecting a bool or a SimpleFilter instance. Got %r' % bool_or_filter) + + return { + True: _always, + False: _never, + }.get(bool_or_filter, bool_or_filter) + + +def to_cli_filter(bool_or_filter): + """ + Accept both booleans and CLIFilters as input and + turn it into a CLIFilter. + """ + if not isinstance(bool_or_filter, (bool, CLIFilter)): + raise TypeError('Expecting a bool or a CLIFilter instance. Got %r' % bool_or_filter) + + return { + True: _always, + False: _never, + }.get(bool_or_filter, bool_or_filter) diff --git a/src/libs/prompt_toolkit/history.py b/src/libs/prompt_toolkit/history.py new file mode 100644 index 0000000..d1eb5f2 --- /dev/null +++ b/src/libs/prompt_toolkit/history.py @@ -0,0 +1,120 @@ +from __future__ import unicode_literals +from abc import ABCMeta, abstractmethod +from six import with_metaclass + +import datetime +import os + +__all__ = ( + 'FileHistory', + 'History', + 'InMemoryHistory', +) + + +class History(with_metaclass(ABCMeta, object)): + """ + Base ``History`` interface. + """ + @abstractmethod + def append(self, string): + " Append string to history. " + + @abstractmethod + def __getitem__(self, key): + " Return one item of the history. It should be accessible like a `list`. " + + @abstractmethod + def __iter__(self): + " Iterate through all the items of the history. Cronologically. " + + @abstractmethod + def __len__(self): + " Return the length of the history. " + + def __bool__(self): + """ + Never evaluate to False, even when the history is empty. + (Python calls __len__ if __bool__ is not implemented.) + This is mainly to allow lazy evaluation:: + + x = history or InMemoryHistory() + """ + return True + + __nonzero__ = __bool__ # For Python 2. + + +class InMemoryHistory(History): + """ + :class:`.History` class that keeps a list of all strings in memory. + """ + def __init__(self): + self.strings = [] + + def append(self, string): + self.strings.append(string) + + def __getitem__(self, key): + return self.strings[key] + + def __iter__(self): + return iter(self.strings) + + def __len__(self): + return len(self.strings) + + +class FileHistory(History): + """ + :class:`.History` class that stores all strings in a file. + """ + def __init__(self, filename): + self.strings = [] + 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): + self.strings.append(string) + + # Save to file. + with open(self.filename, 'ab') as f: + def write(t): + f.write(t.encode('utf-8')) + + write('\n# %s\n' % datetime.datetime.now()) + for line in string.split('\n'): + write('+%s\n' % line) + + def __getitem__(self, key): + return self.strings[key] + + def __iter__(self): + return iter(self.strings) + + def __len__(self): + return len(self.strings) diff --git a/src/libs/prompt_toolkit/input.py b/src/libs/prompt_toolkit/input.py new file mode 100644 index 0000000..a6467aa --- /dev/null +++ b/src/libs/prompt_toolkit/input.py @@ -0,0 +1,135 @@ +""" +Abstraction of CLI Input. +""" +from __future__ import unicode_literals + +from .utils import DummyContext, is_windows +from abc import ABCMeta, abstractmethod +from six import with_metaclass + +import io +import os +import sys + +if is_windows(): + from .terminal.win32_input import raw_mode, cooked_mode +else: + from .terminal.vt100_input import raw_mode, cooked_mode + +__all__ = ( + 'Input', + 'StdinInput', + 'PipeInput', +) + + +class Input(with_metaclass(ABCMeta, object)): + """ + Abstraction for any input. + + An instance of this class can be given to the constructor of a + :class:`~libs.prompt_toolkit.interface.CommandLineInterface` and will also be + passed to the :class:`~libs.prompt_toolkit.eventloop.base.EventLoop`. + """ + @abstractmethod + def fileno(self): + """ + Fileno for putting this in an event loop. + """ + + @abstractmethod + def read(self): + """ + Return text from the input. + """ + + @abstractmethod + def raw_mode(self): + """ + Context manager that turns the input into raw mode. + """ + + @abstractmethod + def cooked_mode(self): + """ + Context manager that turns the input into cooked mode. + """ + + +class StdinInput(Input): + """ + Simple wrapper around stdin. + """ + def __init__(self, stdin=None): + self.stdin = stdin or sys.stdin + + # The input object should be a TTY. + assert self.stdin.isatty() + + # Test whether the given input object has a file descriptor. + # (Idle reports stdin to be a TTY, but fileno() is not implemented.) + try: + # This should not raise, but can return 0. + self.stdin.fileno() + except io.UnsupportedOperation: + if 'idlelib.run' in sys.modules: + raise io.UnsupportedOperation( + 'Stdin is not a terminal. Running from Idle is not supported.') + else: + raise io.UnsupportedOperation('Stdin is not a terminal.') + + def __repr__(self): + return 'StdinInput(stdin=%r)' % (self.stdin,) + + def raw_mode(self): + return raw_mode(self.stdin.fileno()) + + def cooked_mode(self): + return cooked_mode(self.stdin.fileno()) + + def fileno(self): + return self.stdin.fileno() + + def read(self): + return self.stdin.read() + + +class PipeInput(Input): + """ + Input that is send through a pipe. + This is useful if we want to send the input programatically into the + interface, but still use the eventloop. + + Usage:: + + input = PipeInput() + input.send('inputdata') + """ + def __init__(self): + self._r, self._w = os.pipe() + + def fileno(self): + return self._r + + def read(self): + return os.read(self._r) + + def send_text(self, data): + " Send text to the input. " + os.write(self._w, data.encode('utf-8')) + + # Deprecated alias for `send_text`. + send = send_text + + def raw_mode(self): + return DummyContext() + + def cooked_mode(self): + return DummyContext() + + def close(self): + " Close pipe fds. " + os.close(self._r) + os.close(self._w) + self._r = None + self._w = None diff --git a/src/libs/prompt_toolkit/interface.py b/src/libs/prompt_toolkit/interface.py new file mode 100644 index 0000000..1437870 --- /dev/null +++ b/src/libs/prompt_toolkit/interface.py @@ -0,0 +1,1185 @@ +""" +The main `CommandLineInterface` class and logic. +""" +from __future__ import unicode_literals + +import functools +import os +import signal +import six +import sys +import textwrap +import threading +import time +import types +import weakref + +from subprocess import Popen + +from .application import Application, AbortAction +from .buffer import Buffer +from .buffer_mapping import BufferMapping +from .completion import CompleteEvent, get_common_complete_suffix +from .enums import SEARCH_BUFFER +from .eventloop.base import EventLoop +from .eventloop.callbacks import EventLoopCallbacks +from .filters import Condition +from .input import StdinInput, Input +from .key_binding.input_processor import InputProcessor +from .key_binding.input_processor import KeyPress +from .key_binding.registry import Registry +from .key_binding.vi_state import ViState +from .keys import Keys +from .output import Output +from .renderer import Renderer, print_tokens +from .search_state import SearchState +from .utils import Event + +# Following import is required for backwards compatibility. +from .buffer import AcceptAction + +__all__ = ( + 'AbortAction', + 'CommandLineInterface', +) + + +class CommandLineInterface(object): + """ + Wrapper around all the other classes, tying everything together. + + Typical usage:: + + application = Application(...) + cli = CommandLineInterface(application, eventloop) + result = cli.run() + print(result) + + :param application: :class:`~libs.prompt_toolkit.application.Application` instance. + :param eventloop: The :class:`~libs.prompt_toolkit.eventloop.base.EventLoop` to + be used when `run` is called. The easiest way to create + an eventloop is by calling + :meth:`~libs.prompt_toolkit.shortcuts.create_eventloop`. + :param input: :class:`~libs.prompt_toolkit.input.Input` instance. + :param output: :class:`~libs.prompt_toolkit.output.Output` instance. (Probably + Vt100_Output or Win32Output.) + """ + def __init__(self, application, eventloop=None, input=None, output=None): + assert isinstance(application, Application) + assert isinstance(eventloop, EventLoop), 'Passing an eventloop is required.' + assert output is None or isinstance(output, Output) + assert input is None or isinstance(input, Input) + + from .shortcuts import create_output + + self.application = application + self.eventloop = eventloop + self._is_running = False + + # Inputs and outputs. + self.output = output or create_output() + self.input = input or StdinInput(sys.stdin) + + #: The input buffers. + assert isinstance(application.buffers, BufferMapping) + self.buffers = application.buffers + + #: EditingMode.VI or EditingMode.EMACS + self.editing_mode = application.editing_mode + + #: Quoted insert. This flag is set if we go into quoted insert mode. + self.quoted_insert = False + + #: Vi state. (For Vi key bindings.) + self.vi_state = ViState() + + #: The `Renderer` instance. + # Make sure that the same stdout is used, when a custom renderer has been passed. + self.renderer = Renderer( + self.application.style, + self.output, + use_alternate_screen=application.use_alternate_screen, + mouse_support=application.mouse_support) + + #: Render counter. This one is increased every time the UI is rendered. + #: It can be used as a key for caching certain information during one + #: rendering. + self.render_counter = 0 + + #: When there is high CPU, postpone the renderering max x seconds. + #: '0' means: don't postpone. '.5' means: try to draw at least twice a second. + self.max_render_postpone_time = 0 # E.g. .5 + + # Invalidate flag. When 'True', a repaint has been scheduled. + self._invalidated = False + + #: The `InputProcessor` instance. + self.input_processor = InputProcessor(application.key_bindings_registry, weakref.ref(self)) + + self._async_completers = {} # Map buffer name to completer function. + + # Pointer to sub CLI. (In chain of CLI instances.) + self._sub_cli = None # None or other CommandLineInterface instance. + + # Call `add_buffer` for each buffer. + for name, b in self.buffers.items(): + self.add_buffer(name, b) + + # Events. + self.on_buffer_changed = Event(self, application.on_buffer_changed) + self.on_initialize = Event(self, application.on_initialize) + self.on_input_timeout = Event(self, application.on_input_timeout) + self.on_invalidate = Event(self, application.on_invalidate) + self.on_render = Event(self, application.on_render) + self.on_reset = Event(self, application.on_reset) + self.on_start = Event(self, application.on_start) + self.on_stop = Event(self, application.on_stop) + + # Trigger initialize callback. + self.reset() + self.on_initialize += self.application.on_initialize + self.on_initialize.fire() + + @property + def layout(self): + return self.application.layout + + @property + def clipboard(self): + return self.application.clipboard + + @property + def pre_run_callables(self): + return self.application.pre_run_callables + + def add_buffer(self, name, buffer, focus=False): + """ + Insert a new buffer. + """ + assert isinstance(buffer, Buffer) + self.buffers[name] = buffer + + if focus: + self.buffers.focus(name) + + # Create asynchronous completer / auto suggestion. + auto_suggest_function = self._create_auto_suggest_function(buffer) + completer_function = self._create_async_completer(buffer) + self._async_completers[name] = completer_function + + # Complete/suggest on text insert. + def create_on_insert_handler(): + """ + Wrapper around the asynchronous completer and auto suggestion, that + ensures that it's only called while typing if the + `complete_while_typing` filter is enabled. + """ + def on_text_insert(_): + # Only complete when "complete_while_typing" is enabled. + if buffer.completer and buffer.complete_while_typing(): + completer_function() + + # Call auto_suggest. + if buffer.auto_suggest: + auto_suggest_function() + + return on_text_insert + + buffer.on_text_insert += create_on_insert_handler() + + def buffer_changed(_): + """ + When the text in a buffer changes. + (A paste event is also a change, but not an insert. So we don't + want to do autocompletions in this case, but we want to propagate + the on_buffer_changed event.) + """ + # Trigger on_buffer_changed. + self.on_buffer_changed.fire() + + buffer.on_text_changed += buffer_changed + + def start_completion(self, buffer_name=None, select_first=False, + select_last=False, insert_common_part=False, + complete_event=None): + """ + Start asynchronous autocompletion of this buffer. + (This will do nothing if a previous completion was still in progress.) + """ + buffer_name = buffer_name or self.current_buffer_name + completer = self._async_completers.get(buffer_name) + + if completer: + completer(select_first=select_first, + select_last=select_last, + insert_common_part=insert_common_part, + complete_event=CompleteEvent(completion_requested=True)) + + @property + def current_buffer_name(self): + """ + The name of the current :class:`.Buffer`. (Or `None`.) + """ + return self.buffers.current_name(self) + + @property + def current_buffer(self): + """ + The currently focussed :class:`~.Buffer`. + + (This returns a dummy :class:`.Buffer` when none of the actual buffers + has the focus. In this case, it's really not practical to check for + `None` values or catch exceptions every time.) + """ + return self.buffers.current(self) + + def focus(self, buffer_name): + """ + Focus the buffer with the given name on the focus stack. + """ + self.buffers.focus(self, buffer_name) + + def push_focus(self, buffer_name): + """ + Push to the focus stack. + """ + self.buffers.push_focus(self, buffer_name) + + def pop_focus(self): + """ + Pop from the focus stack. + """ + self.buffers.pop_focus(self) + + @property + def terminal_title(self): + """ + Return the current title to be displayed in the terminal. + When this in `None`, the terminal title remains the original. + """ + result = self.application.get_title() + + # Make sure that this function returns a unicode object, + # and not a byte string. + assert result is None or isinstance(result, six.text_type) + return result + + @property + def is_searching(self): + """ + True when we are searching. + """ + return self.current_buffer_name == SEARCH_BUFFER + + def reset(self, reset_current_buffer=False): + """ + Reset everything, for reading the next input. + + :param reset_current_buffer: XXX: not used anymore. The reason for + having this option in the past was when this CommandLineInterface + is run multiple times, that we could reset the buffer content from + the previous run. This is now handled in the AcceptAction. + """ + # Notice that we don't reset the buffers. (This happens just before + # returning, and when we have multiple buffers, we clearly want the + # content in the other buffers to remain unchanged between several + # calls of `run`. (And the same is true for the focus stack.) + + self._exit_flag = False + self._abort_flag = False + + self._return_value = None + + self.renderer.reset() + self.input_processor.reset() + self.layout.reset() + self.vi_state.reset() + + # Search new search state. (Does also remember what has to be + # highlighted.) + self.search_state = SearchState(ignore_case=Condition(lambda: self.is_ignoring_case)) + + # Trigger reset event. + self.on_reset.fire() + + @property + def in_paste_mode(self): + """ True when we are in paste mode. """ + return self.application.paste_mode(self) + + @property + def is_ignoring_case(self): + """ True when we currently ignore casing. """ + return self.application.ignore_case(self) + + def invalidate(self): + """ + Thread safe way of sending a repaint trigger to the input event loop. + """ + # Never schedule a second redraw, when a previous one has not yet been + # executed. (This should protect against other threads calling + # 'invalidate' many times, resulting in 100% CPU.) + if self._invalidated: + return + else: + self._invalidated = True + + # Trigger event. + self.on_invalidate.fire() + + if self.eventloop is not None: + def redraw(): + self._invalidated = False + self._redraw() + + # Call redraw in the eventloop (thread safe). + # Usually with the high priority, in order to make the application + # feel responsive, but this can be tuned by changing the value of + # `max_render_postpone_time`. + if self.max_render_postpone_time: + _max_postpone_until = time.time() + self.max_render_postpone_time + else: + _max_postpone_until = None + + self.eventloop.call_from_executor( + redraw, _max_postpone_until=_max_postpone_until) + + # Depracated alias for 'invalidate'. + request_redraw = invalidate + + def _redraw(self): + """ + Render the command line again. (Not thread safe!) (From other threads, + or if unsure, use :meth:`.CommandLineInterface.invalidate`.) + """ + # Only draw when no sub application was started. + if self._is_running and self._sub_cli is None: + self.render_counter += 1 + self.renderer.render(self, self.layout, is_done=self.is_done) + + # Fire render event. + self.on_render.fire() + + def _on_resize(self): + """ + When the window size changes, we erase the current output and request + again the cursor position. When the CPR answer arrives, the output is + drawn again. + """ + # Erase, request position (when cursor is at the start position) + # and redraw again. -- The order is important. + self.renderer.erase(leave_alternate_screen=False, erase_title=False) + self.renderer.request_absolute_cursor_position() + self._redraw() + + def _load_next_buffer_indexes(self): + for buff, index in self._next_buffer_indexes.items(): + if buff in self.buffers: + self.buffers[buff].working_index = index + + def _pre_run(self, pre_run=None): + " Called during `run`. " + if pre_run: + pre_run() + + # Process registered "pre_run_callables" and clear list. + for c in self.pre_run_callables: + c() + del self.pre_run_callables[:] + + def run(self, reset_current_buffer=False, pre_run=None): + """ + Read input from the command line. + This runs the eventloop until a return value has been set. + + :param reset_current_buffer: XXX: Not used anymore. + :param pre_run: Callable that is called right after the reset has taken + place. This allows custom initialisation. + """ + assert pre_run is None or callable(pre_run) + + try: + self._is_running = True + + self.on_start.fire() + self.reset() + + # Call pre_run. + self._pre_run(pre_run) + + # Run eventloop in raw mode. + with self.input.raw_mode(): + self.renderer.request_absolute_cursor_position() + self._redraw() + + self.eventloop.run(self.input, self.create_eventloop_callbacks()) + finally: + # Clean up renderer. (This will leave the alternate screen, if we use + # that.) + + # If exit/abort haven't been called set, but another exception was + # thrown instead for some reason, make sure that we redraw in exit + # mode. + if not self.is_done: + self._exit_flag = True + self._redraw() + + self.renderer.reset() + self.on_stop.fire() + self._is_running = False + + # Return result. + return self.return_value() + + try: + # The following `run_async` function is compiled at runtime + # because it contains syntax which is not supported on older Python + # versions. (A 'return' inside a generator.) + six.exec_(textwrap.dedent(''' + def run_async(self, reset_current_buffer=True, pre_run=None): + """ + Same as `run`, but this returns a coroutine. + + This is only available on Python >3.3, with asyncio. + """ + # Inline import, because it slows down startup when asyncio is not + # needed. + import asyncio + + @asyncio.coroutine + def run(): + assert pre_run is None or callable(pre_run) + + try: + self._is_running = True + + self.on_start.fire() + self.reset() + + # Call pre_run. + self._pre_run(pre_run) + + with self.input.raw_mode(): + self.renderer.request_absolute_cursor_position() + self._redraw() + + yield from self.eventloop.run_as_coroutine( + self.input, self.create_eventloop_callbacks()) + + return self.return_value() + finally: + if not self.is_done: + self._exit_flag = True + self._redraw() + + self.renderer.reset() + self.on_stop.fire() + self._is_running = False + + return run() + ''')) + except SyntaxError: + # Python2, or early versions of Python 3. + def run_async(self, reset_current_buffer=True, pre_run=None): + """ + Same as `run`, but this returns a coroutine. + + This is only available on Python >3.3, with asyncio. + """ + raise NotImplementedError + + def run_sub_application(self, application, done_callback=None, erase_when_done=False, + _from_application_generator=False): + # `erase_when_done` is deprecated, set Application.erase_when_done instead. + """ + Run a sub :class:`~libs.prompt_toolkit.application.Application`. + + This will suspend the main application and display the sub application + until that one returns a value. The value is returned by calling + `done_callback` with the result. + + The sub application will share the same I/O of the main application. + That means, it uses the same input and output channels and it shares + the same event loop. + + .. note:: Technically, it gets another Eventloop instance, but that is + only a proxy to our main event loop. The reason is that calling + 'stop' --which returns the result of an application when it's + done-- is handled differently. + """ + assert isinstance(application, Application) + assert done_callback is None or callable(done_callback) + + if self._sub_cli is not None: + raise RuntimeError('Another sub application started already.') + + # Erase current application. + if not _from_application_generator: + self.renderer.erase() + + # Callback when the sub app is done. + def done(): + # Redraw sub app in done state. + # and reset the renderer. (This reset will also quit the alternate + # screen, if the sub application used that.) + sub_cli._redraw() + if erase_when_done or application.erase_when_done: + sub_cli.renderer.erase() + sub_cli.renderer.reset() + sub_cli._is_running = False # Don't render anymore. + + self._sub_cli = None + + # Restore main application. + if not _from_application_generator: + self.renderer.request_absolute_cursor_position() + self._redraw() + + # Deliver result. + if done_callback: + done_callback(sub_cli.return_value()) + + # Create sub CommandLineInterface. + sub_cli = CommandLineInterface( + application=application, + eventloop=_SubApplicationEventLoop(self, done), + input=self.input, + output=self.output) + sub_cli._is_running = True # Allow rendering of sub app. + + sub_cli._redraw() + self._sub_cli = sub_cli + + def exit(self): + """ + Set exit. When Control-D has been pressed. + """ + on_exit = self.application.on_exit + self._exit_flag = True + self._redraw() + + if on_exit == AbortAction.RAISE_EXCEPTION: + def eof_error(): + raise EOFError() + self._set_return_callable(eof_error) + + elif on_exit == AbortAction.RETRY: + self.reset() + self.renderer.request_absolute_cursor_position() + self.current_buffer.reset() + + elif on_exit == AbortAction.RETURN_NONE: + self.set_return_value(None) + + def abort(self): + """ + Set abort. When Control-C has been pressed. + """ + on_abort = self.application.on_abort + self._abort_flag = True + self._redraw() + + if on_abort == AbortAction.RAISE_EXCEPTION: + def keyboard_interrupt(): + raise KeyboardInterrupt() + self._set_return_callable(keyboard_interrupt) + + elif on_abort == AbortAction.RETRY: + self.reset() + self.renderer.request_absolute_cursor_position() + self.current_buffer.reset() + + elif on_abort == AbortAction.RETURN_NONE: + self.set_return_value(None) + + # Deprecated aliase for exit/abort. + set_exit = exit + set_abort = abort + + def set_return_value(self, document): + """ + Set a return value. The eventloop can retrieve the result it by calling + `return_value`. + """ + self._set_return_callable(lambda: document) + self._redraw() # Redraw in "done" state, after the return value has been set. + + def _set_return_callable(self, value): + assert callable(value) + self._return_value = value + + if self.eventloop: + self.eventloop.stop() + + def run_in_terminal(self, func, render_cli_done=False): + """ + Run function on the terminal above the prompt. + + What this does is first hiding the prompt, then running this callable + (which can safely output to the terminal), and then again rendering the + prompt which causes the output of this function to scroll above the + prompt. + + :param func: The callable to execute. + :param render_cli_done: When True, render the interface in the + 'Done' state first, then execute the function. If False, + erase the interface first. + + :returns: the result of `func`. + """ + # Draw interface in 'done' state, or erase. + if render_cli_done: + self._return_value = True + self._redraw() + self.renderer.reset() # Make sure to disable mouse mode, etc... + else: + self.renderer.erase() + self._return_value = None + + # Run system command. + with self.input.cooked_mode(): + result = func() + + # Redraw interface again. + self.renderer.reset() + self.renderer.request_absolute_cursor_position() + self._redraw() + + return result + + def run_application_generator(self, coroutine, render_cli_done=False): + """ + EXPERIMENTAL + Like `run_in_terminal`, but takes a generator that can yield Application instances. + + Example: + + def f(): + yield Application1(...) + print('...') + yield Application2(...) + cli.run_in_terminal_async(f) + + The values which are yielded by the given coroutine are supposed to be + `Application` instances that run in the current CLI, all other code is + supposed to be CPU bound, so except for yielding the applications, + there should not be any user interaction or I/O in the given function. + """ + # Draw interface in 'done' state, or erase. + if render_cli_done: + self._return_value = True + self._redraw() + self.renderer.reset() # Make sure to disable mouse mode, etc... + else: + self.renderer.erase() + self._return_value = None + + # Loop through the generator. + g = coroutine() + assert isinstance(g, types.GeneratorType) + + def step_next(send_value=None): + " Execute next step of the coroutine." + try: + # Run until next yield, in cooked mode. + with self.input.cooked_mode(): + result = g.send(send_value) + except StopIteration: + done() + except: + done() + raise + else: + # Process yielded value from coroutine. + assert isinstance(result, Application) + self.run_sub_application(result, done_callback=step_next, + _from_application_generator=True) + + def done(): + # Redraw interface again. + self.renderer.reset() + self.renderer.request_absolute_cursor_position() + self._redraw() + + # Start processing coroutine. + step_next() + + def run_system_command(self, command): + """ + Run system command (While hiding the prompt. When finished, all the + output will scroll above the prompt.) + + :param command: Shell command to be executed. + """ + def wait_for_enter(): + """ + Create a sub application to wait for the enter key press. + This has two advantages over using 'input'/'raw_input': + - This will share the same input/output I/O. + - This doesn't block the event loop. + """ + from .shortcuts import create_prompt_application + + registry = Registry() + + @registry.add_binding(Keys.ControlJ) + @registry.add_binding(Keys.ControlM) + def _(event): + event.cli.set_return_value(None) + + application = create_prompt_application( + message='Press ENTER to continue...', + key_bindings_registry=registry) + self.run_sub_application(application) + + def run(): + # Try to use the same input/output file descriptors as the one, + # used to run this application. + try: + input_fd = self.input.fileno() + except AttributeError: + input_fd = sys.stdin.fileno() + try: + output_fd = self.output.fileno() + except AttributeError: + output_fd = sys.stdout.fileno() + + # Run sub process. + # XXX: This will still block the event loop. + p = Popen(command, shell=True, + stdin=input_fd, stdout=output_fd) + p.wait() + + # Wait for the user to press enter. + wait_for_enter() + + self.run_in_terminal(run) + + def suspend_to_background(self, suspend_group=True): + """ + (Not thread safe -- to be called from inside the key bindings.) + Suspend process. + + :param suspend_group: When true, suspend the whole process group. + (This is the default, and probably what you want.) + """ + # Only suspend when the opperating system supports it. + # (Not on Windows.) + if hasattr(signal, 'SIGTSTP'): + def run(): + # Send `SIGSTP` to own process. + # This will cause it to suspend. + + # Usually we want the whole process group to be suspended. This + # handles the case when input is piped from another process. + if suspend_group: + os.kill(0, signal.SIGTSTP) + else: + os.kill(os.getpid(), signal.SIGTSTP) + + self.run_in_terminal(run) + + def print_tokens(self, tokens, style=None): + """ + Print a list of (Token, text) tuples to the output. + (When the UI is running, this method has to be called through + `run_in_terminal`, otherwise it will destroy the UI.) + + :param style: Style class to use. Defaults to the active style in the CLI. + """ + print_tokens(self.output, tokens, style or self.application.style) + + @property + def is_exiting(self): + """ + ``True`` when the exit flag as been set. + """ + return self._exit_flag + + @property + def is_aborting(self): + """ + ``True`` when the abort flag as been set. + """ + return self._abort_flag + + @property + def is_returning(self): + """ + ``True`` when a return value has been set. + """ + return self._return_value is not None + + def return_value(self): + """ + Get the return value. Not that this method can throw an exception. + """ + # Note that it's a method, not a property, because it can throw + # exceptions. + if self._return_value: + return self._return_value() + + @property + def is_done(self): + return self.is_exiting or self.is_aborting or self.is_returning + + def _create_async_completer(self, buffer): + """ + Create function for asynchronous autocompletion. + (Autocomplete in other thread.) + """ + complete_thread_running = [False] # By ref. + + def completion_does_nothing(document, completion): + """ + Return `True` if applying this completion doesn't have any effect. + (When it doesn't insert any new text. + """ + text_before_cursor = document.text_before_cursor + replaced_text = text_before_cursor[ + len(text_before_cursor) + completion.start_position:] + return replaced_text == completion.text + + def async_completer(select_first=False, select_last=False, + insert_common_part=False, complete_event=None): + document = buffer.document + complete_event = complete_event or CompleteEvent(text_inserted=True) + + # Don't start two threads at the same time. + if complete_thread_running[0]: + return + + # Don't complete when we already have completions. + if buffer.complete_state or not buffer.completer: + return + + # Otherwise, get completions in other thread. + complete_thread_running[0] = True + + def run(): + completions = list(buffer.completer.get_completions(document, complete_event)) + + def callback(): + """ + Set the new complete_state in a safe way. Don't replace an + existing complete_state if we had one. (The user could have + pressed 'Tab' in the meantime. Also don't set it if the text + was changed in the meantime. + """ + complete_thread_running[0] = False + + # When there is only one completion, which has nothing to add, ignore it. + if (len(completions) == 1 and + completion_does_nothing(document, completions[0])): + del completions[:] + + # Set completions if the text was not yet changed. + if buffer.text == document.text and \ + buffer.cursor_position == document.cursor_position and \ + not buffer.complete_state: + + set_completions = True + select_first_anyway = False + + # When the common part has to be inserted, and there + # is a common part. + if insert_common_part: + common_part = get_common_complete_suffix(document, completions) + if common_part: + # Insert the common part, update completions. + buffer.insert_text(common_part) + if len(completions) > 1: + # (Don't call `async_completer` again, but + # recalculate completions. See: + # https://github.com/ipython/ipython/issues/9658) + completions[:] = [ + c.new_completion_from_position(len(common_part)) + for c in completions] + else: + set_completions = False + else: + # When we were asked to insert the "common" + # prefix, but there was no common suffix but + # still exactly one match, then select the + # first. (It could be that we have a completion + # which does * expansion, like '*.py', with + # exactly one match.) + if len(completions) == 1: + select_first_anyway = True + + if set_completions: + buffer.set_completions( + completions=completions, + go_to_first=select_first or select_first_anyway, + go_to_last=select_last) + self.invalidate() + elif not buffer.complete_state: + # Otherwise, restart thread. + async_completer() + + if self.eventloop: + self.eventloop.call_from_executor(callback) + + self.eventloop.run_in_executor(run) + return async_completer + + def _create_auto_suggest_function(self, buffer): + """ + Create function for asynchronous auto suggestion. + (AutoSuggest in other thread.) + """ + suggest_thread_running = [False] # By ref. + + def async_suggestor(): + document = buffer.document + + # Don't start two threads at the same time. + if suggest_thread_running[0]: + return + + # Don't suggest when we already have a suggestion. + if buffer.suggestion or not buffer.auto_suggest: + return + + # Otherwise, get completions in other thread. + suggest_thread_running[0] = True + + def run(): + suggestion = buffer.auto_suggest.get_suggestion(self, buffer, document) + + def callback(): + suggest_thread_running[0] = False + + # Set suggestion only if the text was not yet changed. + if buffer.text == document.text and \ + buffer.cursor_position == document.cursor_position: + + # Set suggestion and redraw interface. + buffer.suggestion = suggestion + self.invalidate() + else: + # Otherwise, restart thread. + async_suggestor() + + if self.eventloop: + self.eventloop.call_from_executor(callback) + + self.eventloop.run_in_executor(run) + return async_suggestor + + def stdout_proxy(self, raw=False): + """ + Create an :class:`_StdoutProxy` class which can be used as a patch for + `sys.stdout`. Writing to this proxy will make sure that the text + appears above the prompt, and that it doesn't destroy the output from + the renderer. + + :param raw: (`bool`) When True, vt100 terminal escape sequences are not + removed/escaped. + """ + return _StdoutProxy(self, raw=raw) + + def patch_stdout_context(self, raw=False, patch_stdout=True, patch_stderr=True): + """ + Return a context manager that will replace ``sys.stdout`` with a proxy + that makes sure that all printed text will appear above the prompt, and + that it doesn't destroy the output from the renderer. + + :param patch_stdout: Replace `sys.stdout`. + :param patch_stderr: Replace `sys.stderr`. + """ + return _PatchStdoutContext( + self.stdout_proxy(raw=raw), + patch_stdout=patch_stdout, patch_stderr=patch_stderr) + + def create_eventloop_callbacks(self): + return _InterfaceEventLoopCallbacks(self) + + +class _InterfaceEventLoopCallbacks(EventLoopCallbacks): + """ + Callbacks on the :class:`.CommandLineInterface` object, to which an + eventloop can talk. + """ + def __init__(self, cli): + assert isinstance(cli, CommandLineInterface) + self.cli = cli + + @property + def _active_cli(self): + """ + Return the active `CommandLineInterface`. + """ + cli = self.cli + + # If there is a sub CLI. That one is always active. + while cli._sub_cli: + cli = cli._sub_cli + + return cli + + def terminal_size_changed(self): + """ + Report terminal size change. This will trigger a redraw. + """ + self._active_cli._on_resize() + + def input_timeout(self): + cli = self._active_cli + cli.on_input_timeout.fire() + + def feed_key(self, key_press): + """ + Feed a key press to the CommandLineInterface. + """ + assert isinstance(key_press, KeyPress) + cli = self._active_cli + + # Feed the key and redraw. + # (When the CLI is in 'done' state, it should return to the event loop + # as soon as possible. Ignore all key presses beyond this point.) + if not cli.is_done: + cli.input_processor.feed(key_press) + cli.input_processor.process_keys() + + +class _PatchStdoutContext(object): + def __init__(self, new_stdout, patch_stdout=True, patch_stderr=True): + self.new_stdout = new_stdout + self.patch_stdout = patch_stdout + self.patch_stderr = patch_stderr + + def __enter__(self): + self.original_stdout = sys.stdout + self.original_stderr = sys.stderr + + if self.patch_stdout: + sys.stdout = self.new_stdout + if self.patch_stderr: + sys.stderr = self.new_stdout + + def __exit__(self, *a, **kw): + if self.patch_stdout: + sys.stdout = self.original_stdout + + if self.patch_stderr: + sys.stderr = self.original_stderr + + +class _StdoutProxy(object): + """ + Proxy for stdout, as returned by + :class:`CommandLineInterface.stdout_proxy`. + """ + def __init__(self, cli, raw=False): + assert isinstance(cli, CommandLineInterface) + assert isinstance(raw, bool) + + self._lock = threading.RLock() + self._cli = cli + self._raw = raw + self._buffer = [] + + self.errors = sys.__stdout__.errors + self.encoding = sys.__stdout__.encoding + + def _do(self, func): + if self._cli._is_running: + run_in_terminal = functools.partial(self._cli.run_in_terminal, func) + self._cli.eventloop.call_from_executor(run_in_terminal) + else: + func() + + def _write(self, data): + """ + Note: print()-statements cause to multiple write calls. + (write('line') and write('\n')). Of course we don't want to call + `run_in_terminal` for every individual call, because that's too + expensive, and as long as the newline hasn't been written, the + text itself is again overwritter by the rendering of the input + command line. Therefor, we have a little buffer which holds the + text until a newline is written to stdout. + """ + if '\n' in data: + # When there is a newline in the data, write everything before the + # newline, including the newline itself. + before, after = data.rsplit('\n', 1) + to_write = self._buffer + [before, '\n'] + self._buffer = [after] + + def run(): + for s in to_write: + if self._raw: + self._cli.output.write_raw(s) + else: + self._cli.output.write(s) + self._do(run) + else: + # Otherwise, cache in buffer. + self._buffer.append(data) + + def write(self, data): + with self._lock: + self._write(data) + + def _flush(self): + def run(): + for s in self._buffer: + if self._raw: + self._cli.output.write_raw(s) + else: + self._cli.output.write(s) + self._buffer = [] + self._cli.output.flush() + self._do(run) + + def flush(self): + """ + Flush buffered output. + """ + with self._lock: + self._flush() + + +class _SubApplicationEventLoop(EventLoop): + """ + Eventloop used by sub applications. + + A sub application is an `Application` that is "spawned" by a parent + application. The parent application is suspended temporarily and the sub + application is displayed instead. + + It doesn't need it's own event loop. The `EventLoopCallbacks` from the + parent application are redirected to the sub application. So if the event + loop that is run by the parent application detects input, the callbacks + will make sure that it's forwarded to the sub application. + + When the sub application has a return value set, it will terminate + by calling the `stop` method of this event loop. This is used to + transfer control back to the parent application. + """ + def __init__(self, cli, stop_callback): + assert isinstance(cli, CommandLineInterface) + assert callable(stop_callback) + + self.cli = cli + self.stop_callback = stop_callback + + def stop(self): + self.stop_callback() + + def close(self): + pass + + def run_in_executor(self, callback): + self.cli.eventloop.run_in_executor(callback) + + def call_from_executor(self, callback, _max_postpone_until=None): + self.cli.eventloop.call_from_executor( + callback, _max_postpone_until=_max_postpone_until) + + def add_reader(self, fd, callback): + self.cli.eventloop.add_reader(fd, callback) + + def remove_reader(self, fd): + self.cli.eventloop.remove_reader(fd) diff --git a/src/libs/prompt_toolkit/key_binding/__init__.py b/src/libs/prompt_toolkit/key_binding/__init__.py new file mode 100644 index 0000000..baffc48 --- /dev/null +++ b/src/libs/prompt_toolkit/key_binding/__init__.py @@ -0,0 +1 @@ +from __future__ import unicode_literals diff --git a/src/libs/prompt_toolkit/key_binding/bindings/__init__.py b/src/libs/prompt_toolkit/key_binding/bindings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/libs/prompt_toolkit/key_binding/bindings/basic.py b/src/libs/prompt_toolkit/key_binding/bindings/basic.py new file mode 100644 index 0000000..63ca074 --- /dev/null +++ b/src/libs/prompt_toolkit/key_binding/bindings/basic.py @@ -0,0 +1,407 @@ +# pylint: disable=function-redefined +from __future__ import unicode_literals + +from libs.prompt_toolkit.enums import DEFAULT_BUFFER +from libs.prompt_toolkit.filters import HasSelection, Condition, EmacsInsertMode, ViInsertMode +from libs.prompt_toolkit.keys import Keys +from libs.prompt_toolkit.layout.screen import Point +from libs.prompt_toolkit.mouse_events import MouseEventType, MouseEvent +from libs.prompt_toolkit.renderer import HeightIsUnknownError +from libs.prompt_toolkit.utils import suspend_to_background_supported, is_windows + +from .named_commands import get_by_name +from ..registry import Registry + + +__all__ = ( + 'load_basic_bindings', + 'load_abort_and_exit_bindings', + 'load_basic_system_bindings', + 'load_auto_suggestion_bindings', +) + +def if_no_repeat(event): + """ Callable that returns True when the previous event was delivered to + another handler. """ + return not event.is_repeat + + +def load_basic_bindings(): + registry = Registry() + insert_mode = ViInsertMode() | EmacsInsertMode() + handle = registry.add_binding + has_selection = HasSelection() + + @handle(Keys.ControlA) + @handle(Keys.ControlB) + @handle(Keys.ControlC) + @handle(Keys.ControlD) + @handle(Keys.ControlE) + @handle(Keys.ControlF) + @handle(Keys.ControlG) + @handle(Keys.ControlH) + @handle(Keys.ControlI) + @handle(Keys.ControlJ) + @handle(Keys.ControlK) + @handle(Keys.ControlL) + @handle(Keys.ControlM) + @handle(Keys.ControlN) + @handle(Keys.ControlO) + @handle(Keys.ControlP) + @handle(Keys.ControlQ) + @handle(Keys.ControlR) + @handle(Keys.ControlS) + @handle(Keys.ControlT) + @handle(Keys.ControlU) + @handle(Keys.ControlV) + @handle(Keys.ControlW) + @handle(Keys.ControlX) + @handle(Keys.ControlY) + @handle(Keys.ControlZ) + @handle(Keys.F1) + @handle(Keys.F2) + @handle(Keys.F3) + @handle(Keys.F4) + @handle(Keys.F5) + @handle(Keys.F6) + @handle(Keys.F7) + @handle(Keys.F8) + @handle(Keys.F9) + @handle(Keys.F10) + @handle(Keys.F11) + @handle(Keys.F12) + @handle(Keys.F13) + @handle(Keys.F14) + @handle(Keys.F15) + @handle(Keys.F16) + @handle(Keys.F17) + @handle(Keys.F18) + @handle(Keys.F19) + @handle(Keys.F20) + @handle(Keys.ControlSpace) + @handle(Keys.ControlBackslash) + @handle(Keys.ControlSquareClose) + @handle(Keys.ControlCircumflex) + @handle(Keys.ControlUnderscore) + @handle(Keys.Backspace) + @handle(Keys.Up) + @handle(Keys.Down) + @handle(Keys.Right) + @handle(Keys.Left) + @handle(Keys.ShiftUp) + @handle(Keys.ShiftDown) + @handle(Keys.ShiftRight) + @handle(Keys.ShiftLeft) + @handle(Keys.Home) + @handle(Keys.End) + @handle(Keys.Delete) + @handle(Keys.ShiftDelete) + @handle(Keys.ControlDelete) + @handle(Keys.PageUp) + @handle(Keys.PageDown) + @handle(Keys.BackTab) + @handle(Keys.Tab) + @handle(Keys.ControlLeft) + @handle(Keys.ControlRight) + @handle(Keys.ControlUp) + @handle(Keys.ControlDown) + @handle(Keys.Insert) + @handle(Keys.Ignore) + def _(event): + """ + First, for any of these keys, Don't do anything by default. Also don't + catch them in the 'Any' handler which will insert them as data. + + If people want to insert these characters as a literal, they can always + do by doing a quoted insert. (ControlQ in emacs mode, ControlV in Vi + mode.) + """ + pass + + # Readline-style bindings. + handle(Keys.Home)(get_by_name('beginning-of-line')) + handle(Keys.End)(get_by_name('end-of-line')) + handle(Keys.Left)(get_by_name('backward-char')) + handle(Keys.Right)(get_by_name('forward-char')) + handle(Keys.ControlUp)(get_by_name('previous-history')) + handle(Keys.ControlDown)(get_by_name('next-history')) + handle(Keys.ControlL)(get_by_name('clear-screen')) + + handle(Keys.ControlK, filter=insert_mode)(get_by_name('kill-line')) + handle(Keys.ControlU, filter=insert_mode)(get_by_name('unix-line-discard')) + handle(Keys.ControlH, filter=insert_mode, save_before=if_no_repeat)( + get_by_name('backward-delete-char')) + handle(Keys.Backspace, filter=insert_mode, save_before=if_no_repeat)( + get_by_name('backward-delete-char')) + handle(Keys.Delete, filter=insert_mode, save_before=if_no_repeat)( + get_by_name('delete-char')) + handle(Keys.ShiftDelete, filter=insert_mode, save_before=if_no_repeat)( + get_by_name('delete-char')) + handle(Keys.Any, filter=insert_mode, save_before=if_no_repeat)( + get_by_name('self-insert')) + handle(Keys.ControlT, filter=insert_mode)(get_by_name('transpose-chars')) + handle(Keys.ControlW, filter=insert_mode)(get_by_name('unix-word-rubout')) + handle(Keys.ControlI, filter=insert_mode)(get_by_name('menu-complete')) + handle(Keys.BackTab, filter=insert_mode)(get_by_name('menu-complete-backward')) + + handle(Keys.PageUp, filter= ~has_selection)(get_by_name('previous-history')) + handle(Keys.PageDown, filter= ~has_selection)(get_by_name('next-history')) + + # CTRL keys. + + text_before_cursor = Condition(lambda cli: cli.current_buffer.text) + handle(Keys.ControlD, filter=text_before_cursor & insert_mode)(get_by_name('delete-char')) + + is_multiline = Condition(lambda cli: cli.current_buffer.is_multiline()) + is_returnable = Condition(lambda cli: cli.current_buffer.accept_action.is_returnable) + + @handle(Keys.ControlJ, filter=is_multiline & insert_mode) + def _(event): + " Newline (in case of multiline input. " + event.current_buffer.newline(copy_margin=not event.cli.in_paste_mode) + + @handle(Keys.ControlJ, filter=~is_multiline & is_returnable) + def _(event): + " Enter, accept input. " + buff = event.current_buffer + buff.accept_action.validate_and_handle(event.cli, buff) + + # Delete the word before the cursor. + + @handle(Keys.Up) + def _(event): + event.current_buffer.auto_up(count=event.arg) + + @handle(Keys.Down) + def _(event): + event.current_buffer.auto_down(count=event.arg) + + @handle(Keys.Delete, filter=has_selection) + def _(event): + data = event.current_buffer.cut_selection() + event.cli.clipboard.set_data(data) + + # Global bindings. + + @handle(Keys.ControlZ) + def _(event): + """ + By default, control-Z should literally insert Ctrl-Z. + (Ansi Ctrl-Z, code 26 in MSDOS means End-Of-File. + In a Python REPL for instance, it's possible to type + Control-Z followed by enter to quit.) + + When the system bindings are loaded and suspend-to-background is + supported, that will override this binding. + """ + event.current_buffer.insert_text(event.data) + + @handle(Keys.CPRResponse, save_before=lambda e: False) + def _(event): + """ + Handle incoming Cursor-Position-Request response. + """ + # The incoming data looks like u'\x1b[35;1R' + # Parse row/col information. + row, col = map(int, event.data[2:-1].split(';')) + + # Report absolute cursor position to the renderer. + event.cli.renderer.report_absolute_cursor_row(row) + + @handle(Keys.BracketedPaste) + def _(event): + " Pasting from clipboard. " + data = event.data + + # Be sure to use \n as line ending. + # Some terminals (Like iTerm2) seem to paste \r\n line endings in a + # bracketed paste. See: https://github.com/ipython/ipython/issues/9737 + data = data.replace('\r\n', '\n') + data = data.replace('\r', '\n') + + event.current_buffer.insert_text(data) + + @handle(Keys.Any, filter=Condition(lambda cli: cli.quoted_insert), eager=True) + def _(event): + """ + Handle quoted insert. + """ + event.current_buffer.insert_text(event.data, overwrite=False) + event.cli.quoted_insert = False + + return registry + + +def load_mouse_bindings(): + """ + Key bindings, required for mouse support. + (Mouse events enter through the key binding system.) + """ + registry = Registry() + + @registry.add_binding(Keys.Vt100MouseEvent) + def _(event): + """ + Handling of incoming mouse event. + """ + # Typical: "Esc[MaB*" + # Urxvt: "Esc[96;14;13M" + # Xterm SGR: "Esc[<64;85;12M" + + # Parse incoming packet. + if event.data[2] == 'M': + # Typical. + mouse_event, x, y = map(ord, event.data[3:]) + mouse_event = { + 32: MouseEventType.MOUSE_DOWN, + 35: MouseEventType.MOUSE_UP, + 96: MouseEventType.SCROLL_UP, + 97: MouseEventType.SCROLL_DOWN, + }.get(mouse_event) + + # Handle situations where `PosixStdinReader` used surrogateescapes. + if x >= 0xdc00: x-= 0xdc00 + if y >= 0xdc00: y-= 0xdc00 + + x -= 32 + y -= 32 + else: + # Urxvt and Xterm SGR. + # When the '<' is not present, we are not using the Xterm SGR mode, + # but Urxvt instead. + data = event.data[2:] + if data[:1] == '<': + sgr = True + data = data[1:] + else: + sgr = False + + # Extract coordinates. + mouse_event, x, y = map(int, data[:-1].split(';')) + m = data[-1] + + # Parse event type. + if sgr: + mouse_event = { + (0, 'M'): MouseEventType.MOUSE_DOWN, + (0, 'm'): MouseEventType.MOUSE_UP, + (64, 'M'): MouseEventType.SCROLL_UP, + (65, 'M'): MouseEventType.SCROLL_DOWN, + }.get((mouse_event, m)) + else: + mouse_event = { + 32: MouseEventType.MOUSE_DOWN, + 35: MouseEventType.MOUSE_UP, + 96: MouseEventType.SCROLL_UP, + 97: MouseEventType.SCROLL_DOWN, + }.get(mouse_event) + + x -= 1 + y -= 1 + + # Only handle mouse events when we know the window height. + if event.cli.renderer.height_is_known and mouse_event is not None: + # Take region above the layout into account. The reported + # coordinates are absolute to the visible part of the terminal. + try: + y -= event.cli.renderer.rows_above_layout + except HeightIsUnknownError: + return + + # Call the mouse handler from the renderer. + handler = event.cli.renderer.mouse_handlers.mouse_handlers[x,y] + handler(event.cli, MouseEvent(position=Point(x=x, y=y), + event_type=mouse_event)) + + @registry.add_binding(Keys.WindowsMouseEvent) + def _(event): + """ + Handling of mouse events for Windows. + """ + assert is_windows() # This key binding should only exist for Windows. + + # Parse data. + event_type, x, y = event.data.split(';') + x = int(x) + y = int(y) + + # Make coordinates absolute to the visible part of the terminal. + screen_buffer_info = event.cli.renderer.output.get_win32_screen_buffer_info() + rows_above_cursor = screen_buffer_info.dwCursorPosition.Y - event.cli.renderer._cursor_pos.y + y -= rows_above_cursor + + # Call the mouse event handler. + handler = event.cli.renderer.mouse_handlers.mouse_handlers[x,y] + handler(event.cli, MouseEvent(position=Point(x=x, y=y), + event_type=event_type)) + + return registry + + +def load_abort_and_exit_bindings(): + """ + Basic bindings for abort (Ctrl-C) and exit (Ctrl-D). + """ + registry = Registry() + handle = registry.add_binding + + @handle(Keys.ControlC) + def _(event): + " Abort when Control-C has been pressed. " + event.cli.abort() + + @Condition + def ctrl_d_condition(cli): + """ Ctrl-D binding is only active when the default buffer is selected + and empty. """ + return (cli.current_buffer_name == DEFAULT_BUFFER and + not cli.current_buffer.text) + + handle(Keys.ControlD, filter=ctrl_d_condition)(get_by_name('end-of-file')) + + return registry + + +def load_basic_system_bindings(): + """ + Basic system bindings (For both Emacs and Vi mode.) + """ + registry = Registry() + + suspend_supported = Condition( + lambda cli: suspend_to_background_supported()) + + @registry.add_binding(Keys.ControlZ, filter=suspend_supported) + def _(event): + """ + Suspend process to background. + """ + event.cli.suspend_to_background() + + return registry + + +def load_auto_suggestion_bindings(): + """ + Key bindings for accepting auto suggestion text. + """ + registry = Registry() + handle = registry.add_binding + + suggestion_available = Condition( + lambda cli: + cli.current_buffer.suggestion is not None and + cli.current_buffer.document.is_cursor_at_the_end) + + @handle(Keys.ControlF, filter=suggestion_available) + @handle(Keys.ControlE, filter=suggestion_available) + @handle(Keys.Right, filter=suggestion_available) + def _(event): + " Accept suggestion. " + b = event.current_buffer + suggestion = b.suggestion + + if suggestion: + b.insert_text(suggestion.text) + + return registry diff --git a/src/libs/prompt_toolkit/key_binding/bindings/completion.py b/src/libs/prompt_toolkit/key_binding/bindings/completion.py new file mode 100644 index 0000000..63069ee --- /dev/null +++ b/src/libs/prompt_toolkit/key_binding/bindings/completion.py @@ -0,0 +1,161 @@ +""" +Key binding handlers for displaying completions. +""" +from __future__ import unicode_literals +from libs.prompt_toolkit.completion import CompleteEvent, get_common_complete_suffix +from libs.prompt_toolkit.utils import get_cwidth +from libs.prompt_toolkit.keys import Keys +from libs.prompt_toolkit.key_binding.registry import Registry + +import math + +__all__ = ( + 'generate_completions', + 'display_completions_like_readline', +) + +def generate_completions(event): + r""" + Tab-completion: where the first tab completes the common suffix and the + second tab lists all the completions. + """ + b = event.current_buffer + + # When already navigating through completions, select the next one. + if b.complete_state: + b.complete_next() + else: + event.cli.start_completion(insert_common_part=True, select_first=False) + + +def display_completions_like_readline(event): + """ + Key binding handler for readline-style tab completion. + This is meant to be as similar as possible to the way how readline displays + completions. + + Generate the completions immediately (blocking) and display them above the + prompt in columns. + + Usage:: + + # Call this handler when 'Tab' has been pressed. + registry.add_binding(Keys.ControlI)(display_completions_like_readline) + """ + # Request completions. + b = event.current_buffer + if b.completer is None: + return + complete_event = CompleteEvent(completion_requested=True) + completions = list(b.completer.get_completions(b.document, complete_event)) + + # Calculate the common suffix. + common_suffix = get_common_complete_suffix(b.document, completions) + + # One completion: insert it. + if len(completions) == 1: + b.delete_before_cursor(-completions[0].start_position) + b.insert_text(completions[0].text) + # Multiple completions with common part. + elif common_suffix: + b.insert_text(common_suffix) + # Otherwise: display all completions. + elif completions: + _display_completions_like_readline(event.cli, completions) + + +def _display_completions_like_readline(cli, completions): + """ + Display the list of completions in columns above the prompt. + This will ask for a confirmation if there are too many completions to fit + on a single page and provide a paginator to walk through them. + """ + from libs.prompt_toolkit.shortcuts import create_confirm_application + assert isinstance(completions, list) + + # Get terminal dimensions. + term_size = cli.output.get_size() + term_width = term_size.columns + term_height = term_size.rows + + # Calculate amount of required columns/rows for displaying the + # completions. (Keep in mind that completions are displayed + # alphabetically column-wise.) + max_compl_width = min(term_width, + max(get_cwidth(c.text) for c in completions) + 1) + column_count = max(1, term_width // max_compl_width) + completions_per_page = column_count * (term_height - 1) + page_count = int(math.ceil(len(completions) / float(completions_per_page))) + # Note: math.ceil can return float on Python2. + + def display(page): + # Display completions. + page_completions = completions[page * completions_per_page: + (page+1) * completions_per_page] + + page_row_count = int(math.ceil(len(page_completions) / float(column_count))) + page_columns = [page_completions[i * page_row_count:(i+1) * page_row_count] + for i in range(column_count)] + + result = [] + for r in range(page_row_count): + for c in range(column_count): + try: + result.append(page_columns[c][r].text.ljust(max_compl_width)) + except IndexError: + pass + result.append('\n') + cli.output.write(''.join(result)) + cli.output.flush() + + # User interaction through an application generator function. + def run(): + if len(completions) > completions_per_page: + # Ask confirmation if it doesn't fit on the screen. + message = 'Display all {} possibilities? (y on n) '.format(len(completions)) + confirm = yield create_confirm_application(message) + + if confirm: + # Display pages. + for page in range(page_count): + display(page) + + if page != page_count - 1: + # Display --MORE-- and go to the next page. + show_more = yield _create_more_application() + if not show_more: + return + else: + cli.output.write('\n'); cli.output.flush() + else: + # Display all completions. + display(0) + + cli.run_application_generator(run, render_cli_done=True) + + +def _create_more_application(): + """ + Create an `Application` instance that displays the "--MORE--". + """ + from libs.prompt_toolkit.shortcuts import create_prompt_application + registry = Registry() + + @registry.add_binding(' ') + @registry.add_binding('y') + @registry.add_binding('Y') + @registry.add_binding(Keys.ControlJ) + @registry.add_binding(Keys.ControlI) # Tab. + def _(event): + event.cli.set_return_value(True) + + @registry.add_binding('n') + @registry.add_binding('N') + @registry.add_binding('q') + @registry.add_binding('Q') + @registry.add_binding(Keys.ControlC) + def _(event): + event.cli.set_return_value(False) + + return create_prompt_application( + '--MORE--', key_bindings_registry=registry, erase_when_done=True) diff --git a/src/libs/prompt_toolkit/key_binding/bindings/emacs.py b/src/libs/prompt_toolkit/key_binding/bindings/emacs.py new file mode 100644 index 0000000..4234e4a --- /dev/null +++ b/src/libs/prompt_toolkit/key_binding/bindings/emacs.py @@ -0,0 +1,451 @@ +# pylint: disable=function-redefined +from __future__ import unicode_literals +from libs.prompt_toolkit.buffer import SelectionType, indent, unindent +from libs.prompt_toolkit.keys import Keys +from libs.prompt_toolkit.enums import IncrementalSearchDirection, SEARCH_BUFFER, SYSTEM_BUFFER +from libs.prompt_toolkit.filters import Condition, EmacsMode, HasSelection, EmacsInsertMode, HasFocus, HasArg +from libs.prompt_toolkit.completion import CompleteEvent + +from .scroll import scroll_page_up, scroll_page_down +from .named_commands import get_by_name +from ..registry import Registry, ConditionalRegistry + +__all__ = ( + 'load_emacs_bindings', + 'load_emacs_search_bindings', + 'load_emacs_system_bindings', + 'load_extra_emacs_page_navigation_bindings', +) + + +def load_emacs_bindings(): + """ + Some e-macs extensions. + """ + # Overview of Readline emacs commands: + # http://www.catonmat.net/download/readline-emacs-editing-mode-cheat-sheet.pdf + registry = ConditionalRegistry(Registry(), EmacsMode()) + handle = registry.add_binding + + insert_mode = EmacsInsertMode() + has_selection = HasSelection() + + @handle(Keys.Escape) + def _(event): + """ + By default, ignore escape key. + + (If we don't put this here, and Esc is followed by a key which sequence + is not handled, we'll insert an Escape character in the input stream. + Something we don't want and happens to easily in emacs mode. + Further, people can always use ControlQ to do a quoted insert.) + """ + pass + + handle(Keys.ControlA)(get_by_name('beginning-of-line')) + handle(Keys.ControlB)(get_by_name('backward-char')) + handle(Keys.ControlDelete, filter=insert_mode)(get_by_name('kill-word')) + handle(Keys.ControlE)(get_by_name('end-of-line')) + handle(Keys.ControlF)(get_by_name('forward-char')) + handle(Keys.ControlLeft)(get_by_name('backward-word')) + handle(Keys.ControlRight)(get_by_name('forward-word')) + handle(Keys.ControlX, 'r', 'y', filter=insert_mode)(get_by_name('yank')) + handle(Keys.ControlY, filter=insert_mode)(get_by_name('yank')) + handle(Keys.Escape, 'b')(get_by_name('backward-word')) + handle(Keys.Escape, 'c', filter=insert_mode)(get_by_name('capitalize-word')) + handle(Keys.Escape, 'd', filter=insert_mode)(get_by_name('kill-word')) + handle(Keys.Escape, 'f')(get_by_name('forward-word')) + handle(Keys.Escape, 'l', filter=insert_mode)(get_by_name('downcase-word')) + handle(Keys.Escape, 'u', filter=insert_mode)(get_by_name('uppercase-word')) + handle(Keys.Escape, 'y', filter=insert_mode)(get_by_name('yank-pop')) + handle(Keys.Escape, Keys.ControlH, filter=insert_mode)(get_by_name('backward-kill-word')) + handle(Keys.Escape, Keys.Backspace, filter=insert_mode)(get_by_name('backward-kill-word')) + handle(Keys.Escape, '\\', filter=insert_mode)(get_by_name('delete-horizontal-space')) + + handle(Keys.ControlUnderscore, save_before=(lambda e: False), filter=insert_mode)( + get_by_name('undo')) + + handle(Keys.ControlX, Keys.ControlU, save_before=(lambda e: False), filter=insert_mode)( + get_by_name('undo')) + + + handle(Keys.Escape, '<', filter= ~has_selection)(get_by_name('beginning-of-history')) + handle(Keys.Escape, '>', filter= ~has_selection)(get_by_name('end-of-history')) + + handle(Keys.Escape, '.', filter=insert_mode)(get_by_name('yank-last-arg')) + handle(Keys.Escape, '_', filter=insert_mode)(get_by_name('yank-last-arg')) + handle(Keys.Escape, Keys.ControlY, filter=insert_mode)(get_by_name('yank-nth-arg')) + handle(Keys.Escape, '#', filter=insert_mode)(get_by_name('insert-comment')) + handle(Keys.ControlO)(get_by_name('operate-and-get-next')) + + # ControlQ does a quoted insert. Not that for vt100 terminals, you have to + # disable flow control by running ``stty -ixon``, otherwise Ctrl-Q and + # Ctrl-S are captured by the terminal. + handle(Keys.ControlQ, filter= ~has_selection)(get_by_name('quoted-insert')) + + handle(Keys.ControlX, '(')(get_by_name('start-kbd-macro')) + handle(Keys.ControlX, ')')(get_by_name('end-kbd-macro')) + handle(Keys.ControlX, 'e')(get_by_name('call-last-kbd-macro')) + + @handle(Keys.ControlN) + def _(event): + " Next line. " + event.current_buffer.auto_down() + + @handle(Keys.ControlP) + def _(event): + " Previous line. " + event.current_buffer.auto_up(count=event.arg) + + def handle_digit(c): + """ + Handle input of arguments. + The first number needs to be preceeded by escape. + """ + @handle(c, filter=HasArg()) + @handle(Keys.Escape, c) + def _(event): + event.append_to_arg_count(c) + + for c in '0123456789': + handle_digit(c) + + @handle(Keys.Escape, '-', filter=~HasArg()) + def _(event): + """ + """ + if event._arg is None: + event.append_to_arg_count('-') + + @handle('-', filter=Condition(lambda cli: cli.input_processor.arg == '-')) + def _(event): + """ + When '-' is typed again, after exactly '-' has been given as an + argument, ignore this. + """ + event.cli.input_processor.arg = '-' + + is_returnable = Condition( + lambda cli: cli.current_buffer.accept_action.is_returnable) + + # Meta + Newline: always accept input. + handle(Keys.Escape, Keys.ControlJ, filter=insert_mode & is_returnable)( + get_by_name('accept-line')) + + def character_search(buff, char, count): + if count < 0: + match = buff.document.find_backwards(char, in_current_line=True, count=-count) + else: + match = buff.document.find(char, in_current_line=True, count=count) + + if match is not None: + buff.cursor_position += match + + @handle(Keys.ControlSquareClose, Keys.Any) + def _(event): + " When Ctl-] + a character is pressed. go to that character. " + # Also named 'character-search' + character_search(event.current_buffer, event.data, event.arg) + + @handle(Keys.Escape, Keys.ControlSquareClose, Keys.Any) + def _(event): + " Like Ctl-], but backwards. " + # Also named 'character-search-backward' + character_search(event.current_buffer, event.data, -event.arg) + + @handle(Keys.Escape, 'a') + def _(event): + " Previous sentence. " + # TODO: + + @handle(Keys.Escape, 'e') + def _(event): + " Move to end of sentence. " + # TODO: + + @handle(Keys.Escape, 't', filter=insert_mode) + def _(event): + """ + Swap the last two words before the cursor. + """ + # TODO + + @handle(Keys.Escape, '*', filter=insert_mode) + def _(event): + """ + `meta-*`: Insert all possible completions of the preceding text. + """ + buff = event.current_buffer + + # List all completions. + complete_event = CompleteEvent(text_inserted=False, completion_requested=True) + completions = list(buff.completer.get_completions(buff.document, complete_event)) + + # Insert them. + text_to_insert = ' '.join(c.text for c in completions) + buff.insert_text(text_to_insert) + + @handle(Keys.ControlX, Keys.ControlX) + def _(event): + """ + Move cursor back and forth between the start and end of the current + line. + """ + buffer = event.current_buffer + + if buffer.document.is_cursor_at_the_end_of_line: + buffer.cursor_position += buffer.document.get_start_of_line_position(after_whitespace=False) + else: + buffer.cursor_position += buffer.document.get_end_of_line_position() + + @handle(Keys.ControlSpace) + def _(event): + """ + Start of the selection (if the current buffer is not empty). + """ + # Take the current cursor position as the start of this selection. + buff = event.current_buffer + if buff.text: + buff.start_selection(selection_type=SelectionType.CHARACTERS) + + @handle(Keys.ControlG, filter= ~has_selection) + def _(event): + """ + Control + G: Cancel completion menu and validation state. + """ + event.current_buffer.complete_state = None + event.current_buffer.validation_error = None + + @handle(Keys.ControlG, filter=has_selection) + def _(event): + """ + Cancel selection. + """ + event.current_buffer.exit_selection() + + @handle(Keys.ControlW, filter=has_selection) + @handle(Keys.ControlX, 'r', 'k', filter=has_selection) + def _(event): + """ + Cut selected text. + """ + data = event.current_buffer.cut_selection() + event.cli.clipboard.set_data(data) + + @handle(Keys.Escape, 'w', filter=has_selection) + def _(event): + """ + Copy selected text. + """ + data = event.current_buffer.copy_selection() + event.cli.clipboard.set_data(data) + + @handle(Keys.Escape, Keys.Left) + def _(event): + """ + Cursor to start of previous word. + """ + buffer = event.current_buffer + buffer.cursor_position += buffer.document.find_previous_word_beginning(count=event.arg) or 0 + + @handle(Keys.Escape, Keys.Right) + def _(event): + """ + Cursor to start of next word. + """ + buffer = event.current_buffer + buffer.cursor_position += buffer.document.find_next_word_beginning(count=event.arg) or \ + buffer.document.get_end_of_document_position() + + @handle(Keys.Escape, '/', filter=insert_mode) + def _(event): + """ + M-/: Complete. + """ + b = event.current_buffer + if b.complete_state: + b.complete_next() + else: + event.cli.start_completion(select_first=True) + + @handle(Keys.ControlC, '>', filter=has_selection) + def _(event): + """ + Indent selected text. + """ + buffer = event.current_buffer + + buffer.cursor_position += buffer.document.get_start_of_line_position(after_whitespace=True) + + from_, to = buffer.document.selection_range() + from_, _ = buffer.document.translate_index_to_position(from_) + to, _ = buffer.document.translate_index_to_position(to) + + indent(buffer, from_, to + 1, count=event.arg) + + @handle(Keys.ControlC, '<', filter=has_selection) + def _(event): + """ + Unindent selected text. + """ + buffer = event.current_buffer + + from_, to = buffer.document.selection_range() + from_, _ = buffer.document.translate_index_to_position(from_) + to, _ = buffer.document.translate_index_to_position(to) + + unindent(buffer, from_, to + 1, count=event.arg) + + return registry + + +def load_emacs_open_in_editor_bindings(): + """ + Pressing C-X C-E will open the buffer in an external editor. + """ + registry = Registry() + + registry.add_binding(Keys.ControlX, Keys.ControlE, + filter=EmacsMode() & ~HasSelection())( + get_by_name('edit-and-execute-command')) + + return registry + + +def load_emacs_system_bindings(): + registry = ConditionalRegistry(Registry(), EmacsMode()) + handle = registry.add_binding + + has_focus = HasFocus(SYSTEM_BUFFER) + + @handle(Keys.Escape, '!', filter= ~has_focus) + def _(event): + """ + M-'!' opens the system prompt. + """ + event.cli.push_focus(SYSTEM_BUFFER) + + @handle(Keys.Escape, filter=has_focus) + @handle(Keys.ControlG, filter=has_focus) + @handle(Keys.ControlC, filter=has_focus) + def _(event): + """ + Cancel system prompt. + """ + event.cli.buffers[SYSTEM_BUFFER].reset() + event.cli.pop_focus() + + @handle(Keys.ControlJ, filter=has_focus) + def _(event): + """ + Run system command. + """ + system_line = event.cli.buffers[SYSTEM_BUFFER] + event.cli.run_system_command(system_line.text) + system_line.reset(append_to_history=True) + + # Focus previous buffer again. + event.cli.pop_focus() + + return registry + + +def load_emacs_search_bindings(get_search_state=None): + registry = ConditionalRegistry(Registry(), EmacsMode()) + handle = registry.add_binding + + has_focus = HasFocus(SEARCH_BUFFER) + + assert get_search_state is None or callable(get_search_state) + + if not get_search_state: + def get_search_state(cli): return cli.search_state + + @handle(Keys.ControlG, filter=has_focus) + @handle(Keys.ControlC, filter=has_focus) + # NOTE: the reason for not also binding Escape to this one, is that we want + # Alt+Enter to accept input directly in incremental search mode. + def _(event): + """ + Abort an incremental search and restore the original line. + """ + search_buffer = event.cli.buffers[SEARCH_BUFFER] + + search_buffer.reset() + event.cli.pop_focus() + + @handle(Keys.ControlJ, filter=has_focus) + def _(event): + """ + When enter pressed in isearch, quit isearch mode. (Multiline + isearch would be too complicated.) + """ + input_buffer = event.cli.buffers.previous(event.cli) + search_buffer = event.cli.buffers[SEARCH_BUFFER] + + # Update search state. + if search_buffer.text: + get_search_state(event.cli).text = search_buffer.text + + # Apply search. + input_buffer.apply_search(get_search_state(event.cli), include_current_position=True) + + # Add query to history of search line. + search_buffer.append_to_history() + search_buffer.reset() + + # Focus previous document again. + event.cli.pop_focus() + + @handle(Keys.ControlR, filter= ~has_focus) + def _(event): + get_search_state(event.cli).direction = IncrementalSearchDirection.BACKWARD + event.cli.push_focus(SEARCH_BUFFER) + + @handle(Keys.ControlS, filter= ~has_focus) + def _(event): + get_search_state(event.cli).direction = IncrementalSearchDirection.FORWARD + event.cli.push_focus(SEARCH_BUFFER) + + def incremental_search(cli, direction, count=1): + " Apply search, but keep search buffer focussed. " + # Update search_state. + search_state = get_search_state(cli) + direction_changed = search_state.direction != direction + + search_state.text = cli.buffers[SEARCH_BUFFER].text + search_state.direction = direction + + # Apply search to current buffer. + if not direction_changed: + input_buffer = cli.buffers.previous(cli) + input_buffer.apply_search(search_state, + include_current_position=False, count=count) + + @handle(Keys.ControlR, filter=has_focus) + @handle(Keys.Up, filter=has_focus) + def _(event): + incremental_search(event.cli, IncrementalSearchDirection.BACKWARD, count=event.arg) + + @handle(Keys.ControlS, filter=has_focus) + @handle(Keys.Down, filter=has_focus) + def _(event): + incremental_search(event.cli, IncrementalSearchDirection.FORWARD, count=event.arg) + + return registry + + +def load_extra_emacs_page_navigation_bindings(): + """ + Key bindings, for scrolling up and down through pages. + This are separate bindings, because GNU readline doesn't have them. + """ + registry = ConditionalRegistry(Registry(), EmacsMode()) + handle = registry.add_binding + + handle(Keys.ControlV)(scroll_page_down) + handle(Keys.PageDown)(scroll_page_down) + handle(Keys.Escape, 'v')(scroll_page_up) + handle(Keys.PageUp)(scroll_page_up) + + return registry diff --git a/src/libs/prompt_toolkit/key_binding/bindings/named_commands.py b/src/libs/prompt_toolkit/key_binding/bindings/named_commands.py new file mode 100644 index 0000000..d7421b4 --- /dev/null +++ b/src/libs/prompt_toolkit/key_binding/bindings/named_commands.py @@ -0,0 +1,578 @@ +""" +Key bindings which are also known by GNU readline by the given names. + +See: http://www.delorie.com/gnu/docs/readline/rlman_13.html +""" +from __future__ import unicode_literals +from libs.prompt_toolkit.enums import IncrementalSearchDirection, SEARCH_BUFFER +from libs.prompt_toolkit.selection import PasteMode +from six.moves import range +import six + +from .completion import generate_completions, display_completions_like_readline +from libs.prompt_toolkit.document import Document +from libs.prompt_toolkit.enums import EditingMode +from libs.prompt_toolkit.key_binding.input_processor import KeyPress +from libs.prompt_toolkit.keys import Keys + +__all__ = ( + 'get_by_name', +) + + +# Registry that maps the Readline command names to their handlers. +_readline_commands = {} + +def register(name): + """ + Store handler in the `_readline_commands` dictionary. + """ + assert isinstance(name, six.text_type) + def decorator(handler): + assert callable(handler) + + _readline_commands[name] = handler + return handler + return decorator + + +def get_by_name(name): + """ + Return the handler for the (Readline) command with the given name. + """ + try: + return _readline_commands[name] + except KeyError: + raise KeyError('Unknown readline command: %r' % name) + +# +# Commands for moving +# See: http://www.delorie.com/gnu/docs/readline/rlman_14.html +# + +@register('beginning-of-line') +def beginning_of_line(event): + " Move to the start of the current line. " + buff = event.current_buffer + buff.cursor_position += buff.document.get_start_of_line_position(after_whitespace=False) + + +@register('end-of-line') +def end_of_line(event): + " Move to the end of the line. " + buff = event.current_buffer + buff.cursor_position += buff.document.get_end_of_line_position() + + +@register('forward-char') +def forward_char(event): + " Move forward a character. " + buff = event.current_buffer + buff.cursor_position += buff.document.get_cursor_right_position(count=event.arg) + + +@register('backward-char') +def backward_char(event): + " Move back a character. " + buff = event.current_buffer + buff.cursor_position += buff.document.get_cursor_left_position(count=event.arg) + + +@register('forward-word') +def forward_word(event): + """ + Move forward to the end of the next word. Words are composed of letters and + digits. + """ + buff = event.current_buffer + pos = buff.document.find_next_word_ending(count=event.arg) + + if pos: + buff.cursor_position += pos + + +@register('backward-word') +def backward_word(event): + """ + Move back to the start of the current or previous word. Words are composed + of letters and digits. + """ + buff = event.current_buffer + pos = buff.document.find_previous_word_beginning(count=event.arg) + + if pos: + buff.cursor_position += pos + + +@register('clear-screen') +def clear_screen(event): + """ + Clear the screen and redraw everything at the top of the screen. + """ + event.cli.renderer.clear() + + +@register('redraw-current-line') +def redraw_current_line(event): + """ + Refresh the current line. + (Readline defines this command, but prompt-toolkit doesn't have it.) + """ + pass + +# +# Commands for manipulating the history. +# See: http://www.delorie.com/gnu/docs/readline/rlman_15.html +# + +@register('accept-line') +def accept_line(event): + " Accept the line regardless of where the cursor is. " + b = event.current_buffer + b.accept_action.validate_and_handle(event.cli, b) + + +@register('previous-history') +def previous_history(event): + " Move `back` through the history list, fetching the previous command. " + event.current_buffer.history_backward(count=event.arg) + + +@register('next-history') +def next_history(event): + " Move `forward` through the history list, fetching the next command. " + event.current_buffer.history_forward(count=event.arg) + + +@register('beginning-of-history') +def beginning_of_history(event): + " Move to the first line in the history. " + event.current_buffer.go_to_history(0) + + +@register('end-of-history') +def end_of_history(event): + """ + Move to the end of the input history, i.e., the line currently being entered. + """ + event.current_buffer.history_forward(count=10**100) + buff = event.current_buffer + buff.go_to_history(len(buff._working_lines) - 1) + + +@register('reverse-search-history') +def reverse_search_history(event): + """ + Search backward starting at the current line and moving `up` through + the history as necessary. This is an incremental search. + """ + event.cli.current_search_state.direction = IncrementalSearchDirection.BACKWARD + event.cli.push_focus(SEARCH_BUFFER) + + +# +# Commands for changing text +# + +@register('end-of-file') +def end_of_file(event): + """ + Exit. + """ + event.cli.exit() + + +@register('delete-char') +def delete_char(event): + " Delete character before the cursor. " + deleted = event.current_buffer.delete(count=event.arg) + if not deleted: + event.cli.output.bell() + + +@register('backward-delete-char') +def backward_delete_char(event): + " Delete the character behind the cursor. " + if event.arg < 0: + # When a negative argument has been given, this should delete in front + # of the cursor. + deleted = event.current_buffer.delete(count=-event.arg) + else: + deleted = event.current_buffer.delete_before_cursor(count=event.arg) + + if not deleted: + event.cli.output.bell() + + +@register('self-insert') +def self_insert(event): + " Insert yourself. " + event.current_buffer.insert_text(event.data * event.arg) + + +@register('transpose-chars') +def transpose_chars(event): + """ + Emulate Emacs transpose-char behavior: at the beginning of the buffer, + do nothing. At the end of a line or buffer, swap the characters before + the cursor. Otherwise, move the cursor right, and then swap the + characters before the cursor. + """ + b = event.current_buffer + p = b.cursor_position + if p == 0: + return + elif p == len(b.text) or b.text[p] == '\n': + b.swap_characters_before_cursor() + else: + b.cursor_position += b.document.get_cursor_right_position() + b.swap_characters_before_cursor() + + +@register('uppercase-word') +def uppercase_word(event): + """ + Uppercase the current (or following) word. + """ + buff = event.current_buffer + + for i in range(event.arg): + pos = buff.document.find_next_word_ending() + words = buff.document.text_after_cursor[:pos] + buff.insert_text(words.upper(), overwrite=True) + + +@register('downcase-word') +def downcase_word(event): + """ + Lowercase the current (or following) word. + """ + buff = event.current_buffer + + for i in range(event.arg): # XXX: not DRY: see meta_c and meta_u!! + pos = buff.document.find_next_word_ending() + words = buff.document.text_after_cursor[:pos] + buff.insert_text(words.lower(), overwrite=True) + + +@register('capitalize-word') +def capitalize_word(event): + """ + Capitalize the current (or following) word. + """ + buff = event.current_buffer + + for i in range(event.arg): + pos = buff.document.find_next_word_ending() + words = buff.document.text_after_cursor[:pos] + buff.insert_text(words.title(), overwrite=True) + + +@register('quoted-insert') +def quoted_insert(event): + """ + Add the next character typed to the line verbatim. This is how to insert + key sequences like C-q, for example. + """ + event.cli.quoted_insert = True + + +# +# Killing and yanking. +# + +@register('kill-line') +def kill_line(event): + """ + Kill the text from the cursor to the end of the line. + + If we are at the end of the line, this should remove the newline. + (That way, it is possible to delete multiple lines by executing this + command multiple times.) + """ + buff = event.current_buffer + if event.arg < 0: + deleted = buff.delete_before_cursor(count=-buff.document.get_start_of_line_position()) + else: + if buff.document.current_char == '\n': + deleted = buff.delete(1) + else: + deleted = buff.delete(count=buff.document.get_end_of_line_position()) + event.cli.clipboard.set_text(deleted) + + +@register('kill-word') +def kill_word(event): + """ + Kill from point to the end of the current word, or if between words, to the + end of the next word. Word boundaries are the same as forward-word. + """ + buff = event.current_buffer + pos = buff.document.find_next_word_ending(count=event.arg) + + if pos: + deleted = buff.delete(count=pos) + event.cli.clipboard.set_text(deleted) + + +@register('unix-word-rubout') +def unix_word_rubout(event, WORD=True): + """ + Kill the word behind point, using whitespace as a word boundary. + Usually bound to ControlW. + """ + buff = event.current_buffer + pos = buff.document.find_start_of_previous_word(count=event.arg, WORD=WORD) + + if pos is None: + # Nothing found? delete until the start of the document. (The + # input starts with whitespace and no words were found before the + # cursor.) + pos = - buff.cursor_position + + if pos: + deleted = buff.delete_before_cursor(count=-pos) + + # If the previous key press was also Control-W, concatenate deleted + # text. + if event.is_repeat: + deleted += event.cli.clipboard.get_data().text + + event.cli.clipboard.set_text(deleted) + else: + # Nothing to delete. Bell. + event.cli.output.bell() + + +@register('backward-kill-word') +def backward_kill_word(event): + """ + Kills the word before point, using "not a letter nor a digit" as a word boundary. + Usually bound to M-Del or M-Backspace. + """ + unix_word_rubout(event, WORD=False) + + +@register('delete-horizontal-space') +def delete_horizontal_space(event): + " Delete all spaces and tabs around point. " + buff = event.current_buffer + text_before_cursor = buff.document.text_before_cursor + text_after_cursor = buff.document.text_after_cursor + + delete_before = len(text_before_cursor) - len(text_before_cursor.rstrip('\t ')) + delete_after = len(text_after_cursor) - len(text_after_cursor.lstrip('\t ')) + + buff.delete_before_cursor(count=delete_before) + buff.delete(count=delete_after) + + +@register('unix-line-discard') +def unix_line_discard(event): + """ + Kill backward from the cursor to the beginning of the current line. + """ + buff = event.current_buffer + + if buff.document.cursor_position_col == 0 and buff.document.cursor_position > 0: + buff.delete_before_cursor(count=1) + else: + deleted = buff.delete_before_cursor(count=-buff.document.get_start_of_line_position()) + event.cli.clipboard.set_text(deleted) + + +@register('yank') +def yank(event): + """ + Paste before cursor. + """ + event.current_buffer.paste_clipboard_data( + event.cli.clipboard.get_data(), count=event.arg, paste_mode=PasteMode.EMACS) + +@register('yank-nth-arg') +def yank_nth_arg(event): + """ + Insert the first argument of the previous command. With an argument, insert + the nth word from the previous command (start counting at 0). + """ + n = (event.arg if event.arg_present else None) + event.current_buffer.yank_nth_arg(n) + + +@register('yank-last-arg') +def yank_last_arg(event): + """ + Like `yank_nth_arg`, but if no argument has been given, yank the last word + of each line. + """ + n = (event.arg if event.arg_present else None) + event.current_buffer.yank_last_arg(n) + +@register('yank-pop') +def yank_pop(event): + """ + Rotate the kill ring, and yank the new top. Only works following yank or + yank-pop. + """ + buff = event.current_buffer + doc_before_paste = buff.document_before_paste + clipboard = event.cli.clipboard + + if doc_before_paste is not None: + buff.document = doc_before_paste + clipboard.rotate() + buff.paste_clipboard_data( + clipboard.get_data(), paste_mode=PasteMode.EMACS) + +# +# Completion. +# + +@register('complete') +def complete(event): + " Attempt to perform completion. " + display_completions_like_readline(event) + + +@register('menu-complete') +def menu_complete(event): + """ + Generate completions, or go to the next completion. (This is the default + way of completing input in libs.prompt_toolkit.) + """ + generate_completions(event) + + +@register('menu-complete-backward') +def menu_complete_backward(event): + " Move backward through the list of possible completions. " + event.current_buffer.complete_previous() + +# +# Keyboard macros. +# + +@register('start-kbd-macro') +def start_kbd_macro(event): + """ + Begin saving the characters typed into the current keyboard macro. + """ + event.cli.input_processor.start_macro() + + +@register('end-kbd-macro') +def start_kbd_macro(event): + """ + Stop saving the characters typed into the current keyboard macro and save + the definition. + """ + event.cli.input_processor.end_macro() + + +@register('call-last-kbd-macro') +def start_kbd_macro(event): + """ + Re-execute the last keyboard macro defined, by making the characters in the + macro appear as if typed at the keyboard. + """ + event.cli.input_processor.call_macro() + + +@register('print-last-kbd-macro') +def print_last_kbd_macro(event): + " Print the last keboard macro. " + # TODO: Make the format suitable for the inputrc file. + def print_macro(): + for k in event.cli.input_processor.macro: + print(k) + event.cli.run_in_terminal(print_macro) + +# +# Miscellaneous Commands. +# + +@register('undo') +def undo(event): + " Incremental undo. " + event.current_buffer.undo() + + +@register('insert-comment') +def insert_comment(event): + """ + Without numeric argument, comment all lines. + With numeric argument, uncomment all lines. + In any case accept the input. + """ + buff = event.current_buffer + + # Transform all lines. + if event.arg != 1: + def change(line): + return line[1:] if line.startswith('#') else line + else: + def change(line): + return '#' + line + + buff.document = Document( + text='\n'.join(map(change, buff.text.splitlines())), + cursor_position=0) + + # Accept input. + buff.accept_action.validate_and_handle(event.cli, buff) + + +@register('vi-editing-mode') +def vi_editing_mode(event): + " Switch to Vi editing mode. " + event.cli.editing_mode = EditingMode.VI + + +@register('emacs-editing-mode') +def emacs_editing_mode(event): + " Switch to Emacs editing mode. " + event.cli.editing_mode = EditingMode.EMACS + + +@register('prefix-meta') +def prefix_meta(event): + """ + Metafy the next character typed. This is for keyboards without a meta key. + + Sometimes people also want to bind other keys to Meta, e.g. 'jj':: + + registry.add_key_binding('j', 'j', filter=ViInsertMode())(prefix_meta) + """ + event.cli.input_processor.feed(KeyPress(Keys.Escape)) + + +@register('operate-and-get-next') +def operate_and_get_next(event): + """ + Accept the current line for execution and fetch the next line relative to + the current line from the history for editing. + """ + buff = event.current_buffer + new_index = buff.working_index + 1 + + # Accept the current input. (This will also redraw the interface in the + # 'done' state.) + buff.accept_action.validate_and_handle(event.cli, buff) + + # Set the new index at the start of the next run. + def set_working_index(): + if new_index < len(buff._working_lines): + buff.working_index = new_index + + event.cli.pre_run_callables.append(set_working_index) + + +@register('edit-and-execute-command') +def edit_and_execute(event): + """ + Invoke an editor on the current command line, and accept the result. + """ + buff = event.current_buffer + + buff.open_in_editor(event.cli) + buff.accept_action.validate_and_handle(event.cli, buff) diff --git a/src/libs/prompt_toolkit/key_binding/bindings/scroll.py b/src/libs/prompt_toolkit/key_binding/bindings/scroll.py new file mode 100644 index 0000000..498da16 --- /dev/null +++ b/src/libs/prompt_toolkit/key_binding/bindings/scroll.py @@ -0,0 +1,185 @@ +""" +Key bindings, for scrolling up and down through pages. + +This are separate bindings, because GNU readline doesn't have them, but +they are very useful for navigating through long multiline buffers, like in +Vi, Emacs, etc... +""" +from __future__ import unicode_literals + +from libs.prompt_toolkit.layout.utils import find_window_for_buffer_name +from six.moves import range + +__all__ = ( + 'scroll_forward', + 'scroll_backward', + 'scroll_half_page_up', + 'scroll_half_page_down', + 'scroll_one_line_up', + 'scroll_one_line_down', +) + + +def _current_window_for_event(event): + """ + Return the `Window` for the currently focussed Buffer. + """ + return find_window_for_buffer_name(event.cli, event.cli.current_buffer_name) + + +def scroll_forward(event, half=False): + """ + Scroll window down. + """ + w = _current_window_for_event(event) + b = event.cli.current_buffer + + if w and w.render_info: + info = w.render_info + ui_content = info.ui_content + + # Height to scroll. + scroll_height = info.window_height + if half: + scroll_height //= 2 + + # Calculate how many lines is equivalent to that vertical space. + y = b.document.cursor_position_row + 1 + height = 0 + while y < ui_content.line_count: + line_height = info.get_height_for_line(y) + + if height + line_height < scroll_height: + height += line_height + y += 1 + else: + break + + b.cursor_position = b.document.translate_row_col_to_index(y, 0) + + +def scroll_backward(event, half=False): + """ + Scroll window up. + """ + w = _current_window_for_event(event) + b = event.cli.current_buffer + + if w and w.render_info: + info = w.render_info + + # Height to scroll. + scroll_height = info.window_height + if half: + scroll_height //= 2 + + # Calculate how many lines is equivalent to that vertical space. + y = max(0, b.document.cursor_position_row - 1) + height = 0 + while y > 0: + line_height = info.get_height_for_line(y) + + if height + line_height < scroll_height: + height += line_height + y -= 1 + else: + break + + b.cursor_position = b.document.translate_row_col_to_index(y, 0) + + +def scroll_half_page_down(event): + """ + Same as ControlF, but only scroll half a page. + """ + scroll_forward(event, half=True) + + +def scroll_half_page_up(event): + """ + Same as ControlB, but only scroll half a page. + """ + scroll_backward(event, half=True) + + +def scroll_one_line_down(event): + """ + scroll_offset += 1 + """ + w = find_window_for_buffer_name(event.cli, event.cli.current_buffer_name) + b = event.cli.current_buffer + + if w: + # When the cursor is at the top, move to the next line. (Otherwise, only scroll.) + if w.render_info: + info = w.render_info + + if w.vertical_scroll < info.content_height - info.window_height: + if info.cursor_position.y <= info.configured_scroll_offsets.top: + b.cursor_position += b.document.get_cursor_down_position() + + w.vertical_scroll += 1 + + +def scroll_one_line_up(event): + """ + scroll_offset -= 1 + """ + w = find_window_for_buffer_name(event.cli, event.cli.current_buffer_name) + b = event.cli.current_buffer + + if w: + # When the cursor is at the bottom, move to the previous line. (Otherwise, only scroll.) + if w.render_info: + info = w.render_info + + if w.vertical_scroll > 0: + first_line_height = info.get_height_for_line(info.first_visible_line()) + + cursor_up = info.cursor_position.y - (info.window_height - 1 - first_line_height - + info.configured_scroll_offsets.bottom) + + # Move cursor up, as many steps as the height of the first line. + # TODO: not entirely correct yet, in case of line wrapping and many long lines. + for _ in range(max(0, cursor_up)): + b.cursor_position += b.document.get_cursor_up_position() + + # Scroll window + w.vertical_scroll -= 1 + + +def scroll_page_down(event): + """ + Scroll page down. (Prefer the cursor at the top of the page, after scrolling.) + """ + w = _current_window_for_event(event) + b = event.cli.current_buffer + + if w and w.render_info: + # Scroll down one page. + line_index = max(w.render_info.last_visible_line(), w.vertical_scroll + 1) + w.vertical_scroll = line_index + + b.cursor_position = b.document.translate_row_col_to_index(line_index, 0) + b.cursor_position += b.document.get_start_of_line_position(after_whitespace=True) + + +def scroll_page_up(event): + """ + Scroll page up. (Prefer the cursor at the bottom of the page, after scrolling.) + """ + w = _current_window_for_event(event) + b = event.cli.current_buffer + + if w and w.render_info: + # Put cursor at the first visible line. (But make sure that the cursor + # moves at least one line up.) + line_index = max(0, min(w.render_info.first_visible_line(), + b.document.cursor_position_row - 1)) + + b.cursor_position = b.document.translate_row_col_to_index(line_index, 0) + b.cursor_position += b.document.get_start_of_line_position(after_whitespace=True) + + # Set the scroll offset. We can safely set it to zero; the Window will + # make sure that it scrolls at least until the cursor becomes visible. + w.vertical_scroll = 0 diff --git a/src/libs/prompt_toolkit/key_binding/bindings/utils.py b/src/libs/prompt_toolkit/key_binding/bindings/utils.py new file mode 100644 index 0000000..5eeda35 --- /dev/null +++ b/src/libs/prompt_toolkit/key_binding/bindings/utils.py @@ -0,0 +1,25 @@ +from __future__ import unicode_literals +from libs.prompt_toolkit.filters import CLIFilter, Always + +__all__ = ( + 'create_handle_decorator', +) + +def create_handle_decorator(registry, filter=Always()): + """ + Create a key handle decorator, which is compatible with `Registry.handle`, + but will chain the given filter to every key binding. + + :param filter: `CLIFilter` + """ + assert isinstance(filter, CLIFilter) + + def handle(*keys, **kw): + # Chain the given filter to the filter of this specific binding. + if 'filter' in kw: + kw['filter'] = kw['filter'] & filter + else: + kw['filter'] = filter + + return registry.add_binding(*keys, **kw) + return handle diff --git a/src/libs/prompt_toolkit/key_binding/bindings/vi.py b/src/libs/prompt_toolkit/key_binding/bindings/vi.py new file mode 100644 index 0000000..6573acf --- /dev/null +++ b/src/libs/prompt_toolkit/key_binding/bindings/vi.py @@ -0,0 +1,1903 @@ +# pylint: disable=function-redefined +from __future__ import unicode_literals +from libs.prompt_toolkit.buffer import ClipboardData, indent, unindent, reshape_text +from libs.prompt_toolkit.document import Document +from libs.prompt_toolkit.enums import IncrementalSearchDirection, SEARCH_BUFFER, SYSTEM_BUFFER +from libs.prompt_toolkit.filters import Filter, Condition, HasArg, Always, IsReadOnly +from libs.prompt_toolkit.filters.cli import ViNavigationMode, ViInsertMode, ViInsertMultipleMode, ViReplaceMode, ViSelectionMode, ViWaitingForTextObjectMode, ViDigraphMode, ViMode +from libs.prompt_toolkit.key_binding.digraphs import DIGRAPHS +from libs.prompt_toolkit.key_binding.vi_state import CharacterFind, InputMode +from libs.prompt_toolkit.keys import Keys +from libs.prompt_toolkit.layout.utils import find_window_for_buffer_name +from libs.prompt_toolkit.selection import SelectionType, SelectionState, PasteMode + +from .scroll import scroll_forward, scroll_backward, scroll_half_page_up, scroll_half_page_down, scroll_one_line_up, scroll_one_line_down, scroll_page_up, scroll_page_down +from .named_commands import get_by_name +from ..registry import Registry, ConditionalRegistry, BaseRegistry + +import libs.prompt_toolkit.filters as filters +from six.moves import range +import codecs +import six +import string + +try: + from itertools import accumulate +except ImportError: # < Python 3.2 + def accumulate(iterable): + " Super simpel 'accumulate' implementation. " + total = 0 + for item in iterable: + total += item + yield total + +__all__ = ( + 'load_vi_bindings', + 'load_vi_search_bindings', + 'load_vi_system_bindings', + 'load_extra_vi_page_navigation_bindings', +) + +if six.PY2: + ascii_lowercase = string.ascii_lowercase.decode('ascii') +else: + ascii_lowercase = string.ascii_lowercase + +vi_register_names = ascii_lowercase + '0123456789' + + +class TextObjectType(object): + EXCLUSIVE = 'EXCLUSIVE' + INCLUSIVE = 'INCLUSIVE' + LINEWISE = 'LINEWISE' + BLOCK = 'BLOCK' + + +class TextObject(object): + """ + Return struct for functions wrapped in ``text_object``. + Both `start` and `end` are relative to the current cursor position. + """ + def __init__(self, start, end=0, type=TextObjectType.EXCLUSIVE): + self.start = start + self.end = end + self.type = type + + @property + def selection_type(self): + if self.type == TextObjectType.LINEWISE: + return SelectionType.LINES + if self.type == TextObjectType.BLOCK: + return SelectionType.BLOCK + else: + return SelectionType.CHARACTERS + + def sorted(self): + """ + Return a (start, end) tuple where start <= end. + """ + if self.start < self.end: + return self.start, self.end + else: + return self.end, self.start + + def operator_range(self, document): + """ + Return a (start, end) tuple with start <= end that indicates the range + operators should operate on. + `buffer` is used to get start and end of line positions. + """ + start, end = self.sorted() + doc = document + + if (self.type == TextObjectType.EXCLUSIVE and + doc.translate_index_to_position(end + doc.cursor_position)[1] == 0): + # If the motion is exclusive and the end of motion is on the first + # column, the end position becomes end of previous line. + end -= 1 + if self.type == TextObjectType.INCLUSIVE: + end += 1 + if self.type == TextObjectType.LINEWISE: + # Select whole lines + row, col = doc.translate_index_to_position(start + doc.cursor_position) + start = doc.translate_row_col_to_index(row, 0) - doc.cursor_position + row, col = doc.translate_index_to_position(end + doc.cursor_position) + end = doc.translate_row_col_to_index(row, len(doc.lines[row])) - doc.cursor_position + return start, end + + def get_line_numbers(self, buffer): + """ + Return a (start_line, end_line) pair. + """ + # Get absolute cursor positions from the text object. + from_, to = self.operator_range(buffer.document) + from_ += buffer.cursor_position + to += buffer.cursor_position + + # Take the start of the lines. + from_, _ = buffer.document.translate_index_to_position(from_) + to, _ = buffer.document.translate_index_to_position(to) + + return from_, to + + def cut(self, buffer): + """ + Turn text object into `ClipboardData` instance. + """ + from_, to = self.operator_range(buffer.document) + + from_ += buffer.cursor_position + to += buffer.cursor_position + to -= 1 # SelectionState does not include the end position, `operator_range` does. + + document = Document(buffer.text, to, SelectionState( + original_cursor_position=from_, type=self.selection_type)) + + new_document, clipboard_data = document.cut_selection() + return new_document, clipboard_data + + +def create_text_object_decorator(registry): + """ + Create a decorator that can be used to register Vi text object implementations. + """ + assert isinstance(registry, BaseRegistry) + + operator_given = ViWaitingForTextObjectMode() + navigation_mode = ViNavigationMode() + selection_mode = ViSelectionMode() + + def text_object_decorator(*keys, **kw): + """ + Register a text object function. + + Usage:: + + @text_object('w', filter=..., no_move_handler=False) + def handler(event): + # Return a text object for this key. + return TextObject(...) + + :param no_move_handler: Disable the move handler in navigation mode. + (It's still active in selection mode.) + """ + filter = kw.pop('filter', Always()) + no_move_handler = kw.pop('no_move_handler', False) + no_selection_handler = kw.pop('no_selection_handler', False) + eager = kw.pop('eager', False) + assert not kw + + def decorator(text_object_func): + assert callable(text_object_func) + + @registry.add_binding(*keys, filter=operator_given & filter, eager=eager) + def _(event): + # Arguments are multiplied. + vi_state = event.cli.vi_state + event._arg = (vi_state.operator_arg or 1) * (event.arg or 1) + + # Call the text object handler. + text_obj = text_object_func(event) + if text_obj is not None: + assert isinstance(text_obj, TextObject) + + # Call the operator function with the text object. + vi_state.operator_func(event, text_obj) + + # Clear operator. + event.cli.vi_state.operator_func = None + event.cli.vi_state.operator_arg = None + + # Register a move operation. (Doesn't need an operator.) + if not no_move_handler: + @registry.add_binding(*keys, filter=~operator_given & filter & navigation_mode, eager=eager) + def _(event): + " Move handler for navigation mode. " + text_object = text_object_func(event) + event.current_buffer.cursor_position += text_object.start + + # Register a move selection operation. + if not no_selection_handler: + @registry.add_binding(*keys, filter=~operator_given & filter & selection_mode, eager=eager) + def _(event): + " Move handler for selection mode. " + text_object = text_object_func(event) + buff = event.current_buffer + + # When the text object has both a start and end position, like 'i(' or 'iw', + # Turn this into a selection, otherwise the cursor. + if text_object.end: + # Take selection positions from text object. + start, end = text_object.operator_range(buff.document) + start += buff.cursor_position + end += buff.cursor_position + + buff.selection_state.original_cursor_position = start + buff.cursor_position = end + + # Take selection type from text object. + if text_object.type == TextObjectType.LINEWISE: + buff.selection_state.type = SelectionType.LINES + else: + buff.selection_state.type = SelectionType.CHARACTERS + else: + event.current_buffer.cursor_position += text_object.start + + # Make it possible to chain @text_object decorators. + return text_object_func + + return decorator + return text_object_decorator + + +def create_operator_decorator(registry): + """ + Create a decorator that can be used for registering Vi operators. + """ + assert isinstance(registry, BaseRegistry) + + operator_given = ViWaitingForTextObjectMode() + navigation_mode = ViNavigationMode() + selection_mode = ViSelectionMode() + + def operator_decorator(*keys, **kw): + """ + Register a Vi operator. + + Usage:: + + @operator('d', filter=...) + def handler(cli, text_object): + # Do something with the text object here. + """ + filter = kw.pop('filter', Always()) + eager = kw.pop('eager', False) + assert not kw + + def decorator(operator_func): + @registry.add_binding(*keys, filter=~operator_given & filter & navigation_mode, eager=eager) + def _(event): + """ + Handle operator in navigation mode. + """ + # When this key binding is matched, only set the operator + # function in the ViState. We should execute it after a text + # object has been received. + event.cli.vi_state.operator_func = operator_func + event.cli.vi_state.operator_arg = event.arg + + @registry.add_binding(*keys, filter=~operator_given & filter & selection_mode, eager=eager) + def _(event): + """ + Handle operator in selection mode. + """ + buff = event.current_buffer + selection_state = buff.selection_state + + # Create text object from selection. + if selection_state.type == SelectionType.LINES: + text_obj_type = TextObjectType.LINEWISE + elif selection_state.type == SelectionType.BLOCK: + text_obj_type = TextObjectType.BLOCK + else: + text_obj_type = TextObjectType.INCLUSIVE + + text_object = TextObject( + selection_state.original_cursor_position - buff.cursor_position, + type=text_obj_type) + + # Execute operator. + operator_func(event, text_object) + + # Quit selection mode. + buff.selection_state = None + + return operator_func + return decorator + return operator_decorator + + +def load_vi_bindings(get_search_state=None): + """ + Vi extensions. + + # Overview of Readline Vi commands: + # http://www.catonmat.net/download/bash-vi-editing-mode-cheat-sheet.pdf + + :param get_search_state: None or a callable that takes a + CommandLineInterface and returns a SearchState. + """ + # Note: Some key bindings have the "~IsReadOnly()" filter added. This + # prevents the handler to be executed when the focus is on a + # read-only buffer. + # This is however only required for those that change the ViState to + # INSERT mode. The `Buffer` class itself throws the + # `EditReadOnlyBuffer` exception for any text operations which is + # handled correctly. There is no need to add "~IsReadOnly" to all key + # bindings that do text manipulation. + + registry = ConditionalRegistry(Registry(), ViMode()) + handle = registry.add_binding + + # Default get_search_state. + if get_search_state is None: + def get_search_state(cli): return cli.search_state + + # (Note: Always take the navigation bindings in read-only mode, even when + # ViState says different.) + navigation_mode = ViNavigationMode() + insert_mode = ViInsertMode() + insert_multiple_mode = ViInsertMultipleMode() + replace_mode = ViReplaceMode() + selection_mode = ViSelectionMode() + operator_given = ViWaitingForTextObjectMode() + digraph_mode = ViDigraphMode() + + vi_transform_functions = [ + # Rot 13 transformation + (('g', '?'), Always(), lambda string: codecs.encode(string, 'rot_13')), + + # To lowercase + (('g', 'u'), Always(), lambda string: string.lower()), + + # To uppercase. + (('g', 'U'), Always(), lambda string: string.upper()), + + # Swap case. + (('g', '~'), Always(), lambda string: string.swapcase()), + (('~', ), Condition(lambda cli: cli.vi_state.tilde_operator), lambda string: string.swapcase()), + ] + + # Insert a character literally (quoted insert). + handle(Keys.ControlV, filter=insert_mode)(get_by_name('quoted-insert')) + + @handle(Keys.Escape) + def _(event): + """ + Escape goes to vi navigation mode. + """ + buffer = event.current_buffer + vi_state = event.cli.vi_state + + if vi_state.input_mode in (InputMode.INSERT, InputMode.REPLACE): + buffer.cursor_position += buffer.document.get_cursor_left_position() + + vi_state.reset(InputMode.NAVIGATION) + + if bool(buffer.selection_state): + buffer.exit_selection() + + @handle('k', filter=selection_mode) + def _(event): + """ + Arrow up in selection mode. + """ + event.current_buffer.cursor_up(count=event.arg) + + @handle('j', filter=selection_mode) + def _(event): + """ + Arrow down in selection mode. + """ + event.current_buffer.cursor_down(count=event.arg) + + @handle(Keys.Up, filter=navigation_mode) + @handle(Keys.ControlP, filter=navigation_mode) + def _(event): + """ + Arrow up and ControlP in navigation mode go up. + """ + event.current_buffer.auto_up(count=event.arg) + + @handle('k', filter=navigation_mode) + def _(event): + """ + Go up, but if we enter a new history entry, move to the start of the + line. + """ + event.current_buffer.auto_up( + count=event.arg, go_to_start_of_line_if_history_changes=True) + + @handle(Keys.Down, filter=navigation_mode) + @handle(Keys.ControlN, filter=navigation_mode) + def _(event): + """ + Arrow down and Control-N in navigation mode. + """ + event.current_buffer.auto_down(count=event.arg) + + @handle('j', filter=navigation_mode) + def _(event): + """ + Go down, but if we enter a new history entry, go to the start of the line. + """ + event.current_buffer.auto_down( + count=event.arg, go_to_start_of_line_if_history_changes=True) + + @handle(Keys.ControlH, filter=navigation_mode) + @handle(Keys.Backspace, filter=navigation_mode) + def _(event): + """ + In navigation-mode, move cursor. + """ + event.current_buffer.cursor_position += \ + event.current_buffer.document.get_cursor_left_position(count=event.arg) + + @handle(Keys.ControlN, filter=insert_mode) + def _(event): + b = event.current_buffer + + if b.complete_state: + b.complete_next() + else: + event.cli.start_completion(select_first=True) + + @handle(Keys.ControlP, filter=insert_mode) + def _(event): + """ + Control-P: To previous completion. + """ + b = event.current_buffer + + if b.complete_state: + b.complete_previous() + else: + event.cli.start_completion(select_last=True) + + @handle(Keys.ControlY, filter=insert_mode) + def _(event): + """ + Accept current completion. + """ + event.current_buffer.complete_state = None + + @handle(Keys.ControlE, filter=insert_mode) + def _(event): + """ + Cancel completion. Go back to originally typed text. + """ + event.current_buffer.cancel_completion() + + @handle(Keys.ControlJ, filter=navigation_mode) # XXX: only if the selected buffer has a return handler. + def _(event): + """ + In navigation mode, pressing enter will always return the input. + """ + b = event.current_buffer + + if b.accept_action.is_returnable: + b.accept_action.validate_and_handle(event.cli, b) + + # ** In navigation mode ** + + # List of navigation commands: http://hea-www.harvard.edu/~fine/Tech/vi.html + + @handle(Keys.Insert, filter=navigation_mode) + def _(event): + " Presing the Insert key. " + event.cli.vi_state.input_mode = InputMode.INSERT + + @handle('a', filter=navigation_mode & ~IsReadOnly()) + # ~IsReadOnly, because we want to stay in navigation mode for + # read-only buffers. + def _(event): + event.current_buffer.cursor_position += event.current_buffer.document.get_cursor_right_position() + event.cli.vi_state.input_mode = InputMode.INSERT + + @handle('A', filter=navigation_mode & ~IsReadOnly()) + def _(event): + event.current_buffer.cursor_position += event.current_buffer.document.get_end_of_line_position() + event.cli.vi_state.input_mode = InputMode.INSERT + + @handle('C', filter=navigation_mode & ~IsReadOnly()) + def _(event): + """ + # Change to end of line. + # Same as 'c$' (which is implemented elsewhere.) + """ + buffer = event.current_buffer + + deleted = buffer.delete(count=buffer.document.get_end_of_line_position()) + event.cli.clipboard.set_text(deleted) + event.cli.vi_state.input_mode = InputMode.INSERT + + @handle('c', 'c', filter=navigation_mode & ~IsReadOnly()) + @handle('S', filter=navigation_mode & ~IsReadOnly()) + def _(event): # TODO: implement 'arg' + """ + Change current line + """ + buffer = event.current_buffer + + # We copy the whole line. + data = ClipboardData(buffer.document.current_line, SelectionType.LINES) + event.cli.clipboard.set_data(data) + + # But we delete after the whitespace + buffer.cursor_position += buffer.document.get_start_of_line_position(after_whitespace=True) + buffer.delete(count=buffer.document.get_end_of_line_position()) + event.cli.vi_state.input_mode = InputMode.INSERT + + @handle('D', filter=navigation_mode) + def _(event): + buffer = event.current_buffer + deleted = buffer.delete(count=buffer.document.get_end_of_line_position()) + event.cli.clipboard.set_text(deleted) + + @handle('d', 'd', filter=navigation_mode) + def _(event): + """ + Delete line. (Or the following 'n' lines.) + """ + buffer = event.current_buffer + + # Split string in before/deleted/after text. + lines = buffer.document.lines + + before = '\n'.join(lines[:buffer.document.cursor_position_row]) + deleted = '\n'.join(lines[buffer.document.cursor_position_row: + buffer.document.cursor_position_row + event.arg]) + after = '\n'.join(lines[buffer.document.cursor_position_row + event.arg:]) + + # Set new text. + if before and after: + before = before + '\n' + + # Set text and cursor position. + buffer.document = Document( + text=before + after, + # Cursor At the start of the first 'after' line, after the leading whitespace. + cursor_position = len(before) + len(after) - len(after.lstrip(' '))) + + # Set clipboard data + event.cli.clipboard.set_data(ClipboardData(deleted, SelectionType.LINES)) + + @handle('x', filter=selection_mode) + def _(event): + """ + Cut selection. + ('x' is not an operator.) + """ + clipboard_data = event.current_buffer.cut_selection() + event.cli.clipboard.set_data(clipboard_data) + + @handle('i', filter=navigation_mode & ~IsReadOnly()) + def _(event): + event.cli.vi_state.input_mode = InputMode.INSERT + + @handle('I', filter=navigation_mode & ~IsReadOnly()) + def _(event): + event.cli.vi_state.input_mode = InputMode.INSERT + event.current_buffer.cursor_position += \ + event.current_buffer.document.get_start_of_line_position(after_whitespace=True) + + @Condition + def in_block_selection(cli): + buff = cli.current_buffer + return buff.selection_state and buff.selection_state.type == SelectionType.BLOCK + + @handle('I', filter=in_block_selection & ~IsReadOnly()) + def go_to_block_selection(event, after=False): + " Insert in block selection mode. " + buff = event.current_buffer + + # Store all cursor positions. + positions = [] + + if after: + def get_pos(from_to): + return from_to[1] + 1 + else: + def get_pos(from_to): + return from_to[0] + + for i, from_to in enumerate(buff.document.selection_ranges()): + positions.append(get_pos(from_to)) + if i == 0: + buff.cursor_position = get_pos(from_to) + + buff.multiple_cursor_positions = positions + + # Go to 'INSERT_MULTIPLE' mode. + event.cli.vi_state.input_mode = InputMode.INSERT_MULTIPLE + buff.exit_selection() + + @handle('A', filter=in_block_selection & ~IsReadOnly()) + def _(event): + go_to_block_selection(event, after=True) + + @handle('J', filter=navigation_mode & ~IsReadOnly()) + def _(event): + " Join lines. " + for i in range(event.arg): + event.current_buffer.join_next_line() + + @handle('g', 'J', filter=navigation_mode & ~IsReadOnly()) + def _(event): + " Join lines without space. " + for i in range(event.arg): + event.current_buffer.join_next_line(separator='') + + @handle('J', filter=selection_mode & ~IsReadOnly()) + def _(event): + " Join selected lines. " + event.current_buffer.join_selected_lines() + + @handle('g', 'J', filter=selection_mode & ~IsReadOnly()) + def _(event): + " Join selected lines without space. " + event.current_buffer.join_selected_lines(separator='') + + @handle('p', filter=navigation_mode) + def _(event): + """ + Paste after + """ + event.current_buffer.paste_clipboard_data( + event.cli.clipboard.get_data(), + count=event.arg, + paste_mode=PasteMode.VI_AFTER) + + @handle('P', filter=navigation_mode) + def _(event): + """ + Paste before + """ + event.current_buffer.paste_clipboard_data( + event.cli.clipboard.get_data(), + count=event.arg, + paste_mode=PasteMode.VI_BEFORE) + + @handle('"', Keys.Any, 'p', filter=navigation_mode) + def _(event): + " Paste from named register. " + c = event.key_sequence[1].data + if c in vi_register_names: + data = event.cli.vi_state.named_registers.get(c) + if data: + event.current_buffer.paste_clipboard_data( + data, count=event.arg, paste_mode=PasteMode.VI_AFTER) + + @handle('"', Keys.Any, 'P', filter=navigation_mode) + def _(event): + " Paste (before) from named register. " + c = event.key_sequence[1].data + if c in vi_register_names: + data = event.cli.vi_state.named_registers.get(c) + if data: + event.current_buffer.paste_clipboard_data( + data, count=event.arg, paste_mode=PasteMode.VI_BEFORE) + + @handle('r', Keys.Any, filter=navigation_mode) + def _(event): + """ + Replace single character under cursor + """ + event.current_buffer.insert_text(event.data * event.arg, overwrite=True) + event.current_buffer.cursor_position -= 1 + + @handle('R', filter=navigation_mode) + def _(event): + """ + Go to 'replace'-mode. + """ + event.cli.vi_state.input_mode = InputMode.REPLACE + + @handle('s', filter=navigation_mode & ~IsReadOnly()) + def _(event): + """ + Substitute with new text + (Delete character(s) and go to insert mode.) + """ + text = event.current_buffer.delete(count=event.arg) + event.cli.clipboard.set_text(text) + event.cli.vi_state.input_mode = InputMode.INSERT + + @handle('u', filter=navigation_mode, save_before=(lambda e: False)) + def _(event): + for i in range(event.arg): + event.current_buffer.undo() + + @handle('V', filter=navigation_mode) + def _(event): + """ + Start lines selection. + """ + event.current_buffer.start_selection(selection_type=SelectionType.LINES) + + @handle(Keys.ControlV, filter=navigation_mode) + def _(event): + " Enter block selection mode. " + event.current_buffer.start_selection(selection_type=SelectionType.BLOCK) + + @handle('V', filter=selection_mode) + def _(event): + """ + Exit line selection mode, or go from non line selection mode to line + selection mode. + """ + selection_state = event.current_buffer.selection_state + + if selection_state.type != SelectionType.LINES: + selection_state.type = SelectionType.LINES + else: + event.current_buffer.exit_selection() + + @handle('v', filter=navigation_mode) + def _(event): + " Enter character selection mode. " + event.current_buffer.start_selection(selection_type=SelectionType.CHARACTERS) + + @handle('v', filter=selection_mode) + def _(event): + """ + Exit character selection mode, or go from non-character-selection mode + to character selection mode. + """ + selection_state = event.current_buffer.selection_state + + if selection_state.type != SelectionType.CHARACTERS: + selection_state.type = SelectionType.CHARACTERS + else: + event.current_buffer.exit_selection() + + @handle(Keys.ControlV, filter=selection_mode) + def _(event): + """ + Exit block selection mode, or go from non block selection mode to block + selection mode. + """ + selection_state = event.current_buffer.selection_state + + if selection_state.type != SelectionType.BLOCK: + selection_state.type = SelectionType.BLOCK + else: + event.current_buffer.exit_selection() + + + @handle('a', 'w', filter=selection_mode) + @handle('a', 'W', filter=selection_mode) + def _(event): + """ + Switch from visual linewise mode to visual characterwise mode. + """ + buffer = event.current_buffer + + if buffer.selection_state and buffer.selection_state.type == SelectionType.LINES: + buffer.selection_state.type = SelectionType.CHARACTERS + + @handle('x', filter=navigation_mode) + def _(event): + """ + Delete character. + """ + text = event.current_buffer.delete(count=event.arg) + event.cli.clipboard.set_text(text) + + @handle('X', filter=navigation_mode) + def _(event): + text = event.current_buffer.delete_before_cursor() + event.cli.clipboard.set_text(text) + + @handle('y', 'y', filter=navigation_mode) + @handle('Y', filter=navigation_mode) + def _(event): + """ + Yank the whole line. + """ + text = '\n'.join(event.current_buffer.document.lines_from_current[:event.arg]) + event.cli.clipboard.set_data(ClipboardData(text, SelectionType.LINES)) + + @handle('+', filter=navigation_mode) + def _(event): + """ + Move to first non whitespace of next line + """ + buffer = event.current_buffer + buffer.cursor_position += buffer.document.get_cursor_down_position(count=event.arg) + buffer.cursor_position += buffer.document.get_start_of_line_position(after_whitespace=True) + + @handle('-', filter=navigation_mode) + def _(event): + """ + Move to first non whitespace of previous line + """ + buffer = event.current_buffer + buffer.cursor_position += buffer.document.get_cursor_up_position(count=event.arg) + buffer.cursor_position += buffer.document.get_start_of_line_position(after_whitespace=True) + + @handle('>', '>', filter=navigation_mode) + def _(event): + """ + Indent lines. + """ + buffer = event.current_buffer + current_row = buffer.document.cursor_position_row + indent(buffer, current_row, current_row + event.arg) + + @handle('<', '<', filter=navigation_mode) + def _(event): + """ + Unindent lines. + """ + current_row = event.current_buffer.document.cursor_position_row + unindent(event.current_buffer, current_row, current_row + event.arg) + + @handle('O', filter=navigation_mode & ~IsReadOnly()) + def _(event): + """ + Open line above and enter insertion mode + """ + event.current_buffer.insert_line_above( + copy_margin=not event.cli.in_paste_mode) + event.cli.vi_state.input_mode = InputMode.INSERT + + @handle('o', filter=navigation_mode & ~IsReadOnly()) + def _(event): + """ + Open line below and enter insertion mode + """ + event.current_buffer.insert_line_below( + copy_margin=not event.cli.in_paste_mode) + event.cli.vi_state.input_mode = InputMode.INSERT + + @handle('~', filter=navigation_mode) + def _(event): + """ + Reverse case of current character and move cursor forward. + """ + buffer = event.current_buffer + c = buffer.document.current_char + + if c is not None and c != '\n': + buffer.insert_text(c.swapcase(), overwrite=True) + + @handle('g', 'u', 'u', filter=navigation_mode & ~IsReadOnly()) + def _(event): + " Lowercase current line. " + buff = event.current_buffer + buff.transform_current_line(lambda s: s.lower()) + + @handle('g', 'U', 'U', filter=navigation_mode & ~IsReadOnly()) + def _(event): + " Uppercase current line. " + buff = event.current_buffer + buff.transform_current_line(lambda s: s.upper()) + + @handle('g', '~', '~', filter=navigation_mode & ~IsReadOnly()) + def _(event): + " Swap case of the current line. " + buff = event.current_buffer + buff.transform_current_line(lambda s: s.swapcase()) + + @handle('#', filter=navigation_mode) + def _(event): + """ + Go to previous occurence of this word. + """ + b = event.cli.current_buffer + + search_state = get_search_state(event.cli) + search_state.text = b.document.get_word_under_cursor() + search_state.direction = IncrementalSearchDirection.BACKWARD + + b.apply_search(search_state, count=event.arg, + include_current_position=False) + + @handle('*', filter=navigation_mode) + def _(event): + """ + Go to next occurence of this word. + """ + b = event.cli.current_buffer + + search_state = get_search_state(event.cli) + search_state.text = b.document.get_word_under_cursor() + search_state.direction = IncrementalSearchDirection.FORWARD + + b.apply_search(search_state, count=event.arg, + include_current_position=False) + + @handle('(', filter=navigation_mode) + def _(event): + # TODO: go to begin of sentence. + # XXX: should become text_object. + pass + + @handle(')', filter=navigation_mode) + def _(event): + # TODO: go to end of sentence. + # XXX: should become text_object. + pass + + operator = create_operator_decorator(registry) + text_object = create_text_object_decorator(registry) + + @text_object(Keys.Any, filter=operator_given) + def _(event): + """ + Unknown key binding while waiting for a text object. + """ + event.cli.output.bell() + + # + # *** Operators *** + # + + def create_delete_and_change_operators(delete_only, with_register=False): + """ + Delete and change operators. + + :param delete_only: Create an operator that deletes, but doesn't go to insert mode. + :param with_register: Copy the deleted text to this named register instead of the clipboard. + """ + if with_register: + handler_keys = ('"', Keys.Any, 'cd'[delete_only]) + else: + handler_keys = 'cd'[delete_only] + + @operator(*handler_keys, filter=~IsReadOnly()) + def delete_or_change_operator(event, text_object): + clipboard_data = None + buff = event.current_buffer + + if text_object: + new_document, clipboard_data = text_object.cut(buff) + buff.document = new_document + + # Set deleted/changed text to clipboard or named register. + if clipboard_data and clipboard_data.text: + if with_register: + reg_name = event.key_sequence[1].data + if reg_name in vi_register_names: + event.cli.vi_state.named_registers[reg_name] = clipboard_data + else: + event.cli.clipboard.set_data(clipboard_data) + + # Only go back to insert mode in case of 'change'. + if not delete_only: + event.cli.vi_state.input_mode = InputMode.INSERT + + create_delete_and_change_operators(False, False) + create_delete_and_change_operators(False, True) + create_delete_and_change_operators(True, False) + create_delete_and_change_operators(True, True) + + def create_transform_handler(filter, transform_func, *a): + @operator(*a, filter=filter & ~IsReadOnly()) + def _(event, text_object): + """ + Apply transformation (uppercase, lowercase, rot13, swap case). + """ + buff = event.current_buffer + start, end = text_object.operator_range(buff.document) + + if start < end: + # Transform. + buff.transform_region( + buff.cursor_position + start, + buff.cursor_position + end, + transform_func) + + # Move cursor + buff.cursor_position += (text_object.end or text_object.start) + + for k, f, func in vi_transform_functions: + create_transform_handler(f, func, *k) + + @operator('y') + def yank_handler(event, text_object): + """ + Yank operator. (Copy text.) + """ + _, clipboard_data = text_object.cut(event.current_buffer) + if clipboard_data.text: + event.cli.clipboard.set_data(clipboard_data) + + @operator('"', Keys.Any, 'y') + def _(event, text_object): + " Yank selection to named register. " + c = event.key_sequence[1].data + if c in vi_register_names: + _, clipboard_data = text_object.cut(event.current_buffer) + event.cli.vi_state.named_registers[c] = clipboard_data + + @operator('>') + def _(event, text_object): + """ + Indent. + """ + buff = event.current_buffer + from_, to = text_object.get_line_numbers(buff) + indent(buff, from_, to + 1, count=event.arg) + + @operator('<') + def _(event, text_object): + """ + Unindent. + """ + buff = event.current_buffer + from_, to = text_object.get_line_numbers(buff) + unindent(buff, from_, to + 1, count=event.arg) + + @operator('g', 'q') + def _(event, text_object): + """ + Reshape text. + """ + buff = event.current_buffer + from_, to = text_object.get_line_numbers(buff) + reshape_text(buff, from_, to) + + # + # *** Text objects *** + # + + @text_object('b') + def _(event): + """ Move one word or token left. """ + return TextObject(event.current_buffer.document.find_start_of_previous_word(count=event.arg) or 0) + + @text_object('B') + def _(event): + """ Move one non-blank word left """ + return TextObject(event.current_buffer.document.find_start_of_previous_word(count=event.arg, WORD=True) or 0) + + @text_object('$') + def key_dollar(event): + """ 'c$', 'd$' and '$': Delete/change/move until end of line. """ + return TextObject(event.current_buffer.document.get_end_of_line_position()) + + @text_object('w') + def _(event): + """ 'word' forward. 'cw', 'dw', 'w': Delete/change/move one word. """ + return TextObject(event.current_buffer.document.find_next_word_beginning(count=event.arg) or + event.current_buffer.document.get_end_of_document_position()) + + @text_object('W') + def _(event): + """ 'WORD' forward. 'cW', 'dW', 'W': Delete/change/move one WORD. """ + return TextObject(event.current_buffer.document.find_next_word_beginning(count=event.arg, WORD=True) or + event.current_buffer.document.get_end_of_document_position()) + + @text_object('e') + def _(event): + """ End of 'word': 'ce', 'de', 'e' """ + end = event.current_buffer.document.find_next_word_ending(count=event.arg) + return TextObject(end - 1 if end else 0, type=TextObjectType.INCLUSIVE) + + @text_object('E') + def _(event): + """ End of 'WORD': 'cE', 'dE', 'E' """ + end = event.current_buffer.document.find_next_word_ending(count=event.arg, WORD=True) + return TextObject(end - 1 if end else 0, type=TextObjectType.INCLUSIVE) + + @text_object('i', 'w', no_move_handler=True) + def _(event): + """ Inner 'word': ciw and diw """ + start, end = event.current_buffer.document.find_boundaries_of_current_word() + return TextObject(start, end) + + @text_object('a', 'w', no_move_handler=True) + def _(event): + """ A 'word': caw and daw """ + start, end = event.current_buffer.document.find_boundaries_of_current_word(include_trailing_whitespace=True) + return TextObject(start, end) + + @text_object('i', 'W', no_move_handler=True) + def _(event): + """ Inner 'WORD': ciW and diW """ + start, end = event.current_buffer.document.find_boundaries_of_current_word(WORD=True) + return TextObject(start, end) + + @text_object('a', 'W', no_move_handler=True) + def _(event): + """ A 'WORD': caw and daw """ + start, end = event.current_buffer.document.find_boundaries_of_current_word(WORD=True, include_trailing_whitespace=True) + return TextObject(start, end) + + @text_object('a', 'p', no_move_handler=True) + def _(event): + """ + Auto paragraph. + """ + start = event.current_buffer.document.start_of_paragraph() + end = event.current_buffer.document.end_of_paragraph(count=event.arg) + return TextObject(start, end) + + @text_object('^') + def key_circumflex(event): + """ 'c^', 'd^' and '^': Soft start of line, after whitespace. """ + return TextObject(event.current_buffer.document.get_start_of_line_position(after_whitespace=True)) + + @text_object('0') + def key_zero(event): + """ + 'c0', 'd0': Hard start of line, before whitespace. + (The move '0' key is implemented elsewhere, because a '0' could also change the `arg`.) + """ + return TextObject(event.current_buffer.document.get_start_of_line_position(after_whitespace=False)) + + def create_ci_ca_handles(ci_start, ci_end, inner, key=None): + # TODO: 'dat', 'dit', (tags (like xml) + """ + Delete/Change string between this start and stop character. But keep these characters. + This implements all the ci", ci<, ci{, ci(, di", di<, ca", ca<, ... combinations. + """ + def handler(event): + if ci_start == ci_end: + # Quotes + start = event.current_buffer.document.find_backwards(ci_start, in_current_line=False) + end = event.current_buffer.document.find(ci_end, in_current_line=False) + else: + # Brackets + start = event.current_buffer.document.find_enclosing_bracket_left(ci_start, ci_end) + end = event.current_buffer.document.find_enclosing_bracket_right(ci_start, ci_end) + + if start is not None and end is not None: + offset = 0 if inner else 1 + return TextObject(start + 1 - offset, end + offset) + else: + # Nothing found. + return TextObject(0) + + if key is None: + text_object('ai'[inner], ci_start, no_move_handler=True)(handler) + text_object('ai'[inner], ci_end, no_move_handler=True)(handler) + else: + text_object('ai'[inner], key, no_move_handler=True)(handler) + + for inner in (False, True): + for ci_start, ci_end in [('"', '"'), ("'", "'"), ("`", "`"), + ('[', ']'), ('<', '>'), ('{', '}'), ('(', ')')]: + create_ci_ca_handles(ci_start, ci_end, inner) + + create_ci_ca_handles('(', ')', inner, 'b') # 'dab', 'dib' + create_ci_ca_handles('{', '}', inner, 'B') # 'daB', 'diB' + + @text_object('{') + def _(event): + """ + Move to previous blank-line separated section. + Implements '{', 'c{', 'd{', 'y{' + """ + index = event.current_buffer.document.start_of_paragraph( + count=event.arg, before=True) + return TextObject(index) + + @text_object('}') + def _(event): + """ + Move to next blank-line separated section. + Implements '}', 'c}', 'd}', 'y}' + """ + index = event.current_buffer.document.end_of_paragraph(count=event.arg, after=True) + return TextObject(index) + + @text_object('f', Keys.Any) + def _(event): + """ + Go to next occurance of character. Typing 'fx' will move the + cursor to the next occurance of character. 'x'. + """ + event.cli.vi_state.last_character_find = CharacterFind(event.data, False) + match = event.current_buffer.document.find( + event.data, in_current_line=True, count=event.arg) + if match: + return TextObject(match, type=TextObjectType.INCLUSIVE) + else: + return TextObject(0) + + @text_object('F', Keys.Any) + def _(event): + """ + Go to previous occurance of character. Typing 'Fx' will move the + cursor to the previous occurance of character. 'x'. + """ + event.cli.vi_state.last_character_find = CharacterFind(event.data, True) + return TextObject(event.current_buffer.document.find_backwards( + event.data, in_current_line=True, count=event.arg) or 0) + + @text_object('t', Keys.Any) + def _(event): + """ + Move right to the next occurance of c, then one char backward. + """ + event.cli.vi_state.last_character_find = CharacterFind(event.data, False) + match = event.current_buffer.document.find( + event.data, in_current_line=True, count=event.arg) + if match: + return TextObject(match - 1, type=TextObjectType.INCLUSIVE) + else: + return TextObject(0) + + @text_object('T', Keys.Any) + def _(event): + """ + Move left to the previous occurance of c, then one char forward. + """ + event.cli.vi_state.last_character_find = CharacterFind(event.data, True) + match = event.current_buffer.document.find_backwards( + event.data, in_current_line=True, count=event.arg) + return TextObject(match + 1 if match else 0) + + def repeat(reverse): + """ + Create ',' and ';' commands. + """ + @text_object(',' if reverse else ';') + def _(event): + # Repeat the last 'f'/'F'/'t'/'T' command. + pos = 0 + vi_state = event.cli.vi_state + + type = TextObjectType.EXCLUSIVE + + if vi_state.last_character_find: + char = vi_state.last_character_find.character + backwards = vi_state.last_character_find.backwards + + if reverse: + backwards = not backwards + + if backwards: + pos = event.current_buffer.document.find_backwards(char, in_current_line=True, count=event.arg) + else: + pos = event.current_buffer.document.find(char, in_current_line=True, count=event.arg) + type = TextObjectType.INCLUSIVE + if pos: + return TextObject(pos, type=type) + else: + return TextObject(0) + repeat(True) + repeat(False) + + @text_object('h') + @text_object(Keys.Left) + def _(event): + """ Implements 'ch', 'dh', 'h': Cursor left. """ + return TextObject(event.current_buffer.document.get_cursor_left_position(count=event.arg)) + + @text_object('j', no_move_handler=True, no_selection_handler=True) + # Note: We also need `no_selection_handler`, because we in + # selection mode, we prefer the other 'j' binding that keeps + # `buffer.preferred_column`. + def _(event): + """ Implements 'cj', 'dj', 'j', ... Cursor up. """ + return TextObject(event.current_buffer.document.get_cursor_down_position(count=event.arg), + type=TextObjectType.LINEWISE) + + @text_object('k', no_move_handler=True, no_selection_handler=True) + def _(event): + """ Implements 'ck', 'dk', 'k', ... Cursor up. """ + return TextObject(event.current_buffer.document.get_cursor_up_position(count=event.arg), + type=TextObjectType.LINEWISE) + + @text_object('l') + @text_object(' ') + @text_object(Keys.Right) + def _(event): + """ Implements 'cl', 'dl', 'l', 'c ', 'd ', ' '. Cursor right. """ + return TextObject(event.current_buffer.document.get_cursor_right_position(count=event.arg)) + + @text_object('H') + def _(event): + """ + Moves to the start of the visible region. (Below the scroll offset.) + Implements 'cH', 'dH', 'H'. + """ + w = find_window_for_buffer_name(event.cli, event.cli.current_buffer_name) + b = event.current_buffer + + if w and w.render_info: + # When we find a Window that has BufferControl showing this window, + # move to the start of the visible area. + pos = (b.document.translate_row_col_to_index( + w.render_info.first_visible_line(after_scroll_offset=True), 0) - + b.cursor_position) + + else: + # Otherwise, move to the start of the input. + pos = -len(b.document.text_before_cursor) + return TextObject(pos, type=TextObjectType.LINEWISE) + + @text_object('M') + def _(event): + """ + Moves cursor to the vertical center of the visible region. + Implements 'cM', 'dM', 'M'. + """ + w = find_window_for_buffer_name(event.cli, event.cli.current_buffer_name) + b = event.current_buffer + + if w and w.render_info: + # When we find a Window that has BufferControl showing this window, + # move to the center of the visible area. + pos = (b.document.translate_row_col_to_index( + w.render_info.center_visible_line(), 0) - + b.cursor_position) + + else: + # Otherwise, move to the start of the input. + pos = -len(b.document.text_before_cursor) + return TextObject(pos, type=TextObjectType.LINEWISE) + + @text_object('L') + def _(event): + """ + Moves to the end of the visible region. (Above the scroll offset.) + """ + w = find_window_for_buffer_name(event.cli, event.cli.current_buffer_name) + b = event.current_buffer + + if w and w.render_info: + # When we find a Window that has BufferControl showing this window, + # move to the end of the visible area. + pos = (b.document.translate_row_col_to_index( + w.render_info.last_visible_line(before_scroll_offset=True), 0) - + b.cursor_position) + + else: + # Otherwise, move to the end of the input. + pos = len(b.document.text_after_cursor) + return TextObject(pos, type=TextObjectType.LINEWISE) + + @text_object('n', no_move_handler=True) + def _(event): + " Search next. " + buff = event.current_buffer + cursor_position = buff.get_search_position( + get_search_state(event.cli), include_current_position=False, + count=event.arg) + return TextObject(cursor_position - buff.cursor_position) + + @handle('n', filter=navigation_mode) + def _(event): + " Search next in navigation mode. (This goes through the history.) " + event.current_buffer.apply_search( + get_search_state(event.cli), include_current_position=False, + count=event.arg) + + @text_object('N', no_move_handler=True) + def _(event): + " Search previous. " + buff = event.current_buffer + cursor_position = buff.get_search_position( + ~get_search_state(event.cli), include_current_position=False, + count=event.arg) + return TextObject(cursor_position - buff.cursor_position) + + @handle('N', filter=navigation_mode) + def _(event): + " Search previous in navigation mode. (This goes through the history.) " + event.current_buffer.apply_search( + ~get_search_state(event.cli), include_current_position=False, + count=event.arg) + + @handle('z', '+', filter=navigation_mode|selection_mode) + @handle('z', 't', filter=navigation_mode|selection_mode) + @handle('z', Keys.ControlJ, filter=navigation_mode|selection_mode) + def _(event): + """ + Scrolls the window to makes the current line the first line in the visible region. + """ + w = find_window_for_buffer_name(event.cli, event.cli.current_buffer_name) + b = event.cli.current_buffer + w.vertical_scroll = b.document.cursor_position_row + + @handle('z', '-', filter=navigation_mode|selection_mode) + @handle('z', 'b', filter=navigation_mode|selection_mode) + def _(event): + """ + Scrolls the window to makes the current line the last line in the visible region. + """ + w = find_window_for_buffer_name(event.cli, event.cli.current_buffer_name) + + # We can safely set the scroll offset to zero; the Window will meke + # sure that it scrolls at least enough to make the cursor visible + # again. + w.vertical_scroll = 0 + + @handle('z', 'z', filter=navigation_mode|selection_mode) + def _(event): + """ + Center Window vertically around cursor. + """ + w = find_window_for_buffer_name(event.cli, event.cli.current_buffer_name) + b = event.cli.current_buffer + + if w and w.render_info: + info = w.render_info + + # Calculate the offset that we need in order to position the row + # containing the cursor in the center. + scroll_height = info.window_height // 2 + + y = max(0, b.document.cursor_position_row - 1) + height = 0 + while y > 0: + line_height = info.get_height_for_line(y) + + if height + line_height < scroll_height: + height += line_height + y -= 1 + else: + break + + w.vertical_scroll = y + + @text_object('%') + def _(event): + """ + Implements 'c%', 'd%', '%, 'y%' (Move to corresponding bracket.) + If an 'arg' has been given, go this this % position in the file. + """ + buffer = event.current_buffer + + if event._arg: + # If 'arg' has been given, the meaning of % is to go to the 'x%' + # row in the file. + if 0 < event.arg <= 100: + absolute_index = buffer.document.translate_row_col_to_index( + int((event.arg * buffer.document.line_count - 1) / 100), 0) + return TextObject(absolute_index - buffer.document.cursor_position, type=TextObjectType.LINEWISE) + else: + return TextObject(0) # Do nothing. + + else: + # Move to the corresponding opening/closing bracket (()'s, []'s and {}'s). + match = buffer.document.find_matching_bracket_position() + if match: + return TextObject(match, type=TextObjectType.INCLUSIVE) + else: + return TextObject(0) + + @text_object('|') + def _(event): + # Move to the n-th column (you may specify the argument n by typing + # it on number keys, for example, 20|). + return TextObject(event.current_buffer.document.get_column_cursor_position(event.arg - 1)) + + @text_object('g', 'g') + def _(event): + """ + Implements 'gg', 'cgg', 'ygg' + """ + d = event.current_buffer.document + + if event._arg: + # Move to the given line. + return TextObject(d.translate_row_col_to_index(event.arg - 1, 0) - d.cursor_position, type=TextObjectType.LINEWISE) + else: + # Move to the top of the input. + return TextObject(d.get_start_of_document_position(), type=TextObjectType.LINEWISE) + + @text_object('g', '_') + def _(event): + """ + Go to last non-blank of line. + 'g_', 'cg_', 'yg_', etc.. + """ + return TextObject( + event.current_buffer.document.last_non_blank_of_current_line_position(), type=TextObjectType.INCLUSIVE) + + @text_object('g', 'e') + def _(event): + """ + Go to last character of previous word. + 'ge', 'cge', 'yge', etc.. + """ + prev_end = event.current_buffer.document.find_previous_word_ending(count=event.arg) + return TextObject(prev_end - 1 if prev_end is not None else 0, type=TextObjectType.INCLUSIVE) + + @text_object('g', 'E') + def _(event): + """ + Go to last character of previous WORD. + 'gE', 'cgE', 'ygE', etc.. + """ + prev_end = event.current_buffer.document.find_previous_word_ending(count=event.arg, WORD=True) + return TextObject(prev_end - 1 if prev_end is not None else 0, type=TextObjectType.INCLUSIVE) + + @text_object('g', 'm') + def _(event): + """ + Like g0, but half a screenwidth to the right. (Or as much as possible.) + """ + w = find_window_for_buffer_name(event.cli, event.cli.current_buffer_name) + buff = event.current_buffer + + if w and w.render_info: + width = w.render_info.window_width + start = buff.document.get_start_of_line_position(after_whitespace=False) + start += int(min(width / 2, len(buff.document.current_line))) + + return TextObject(start, type=TextObjectType.INCLUSIVE) + return TextObject(0) + + @text_object('G') + def _(event): + """ + Go to the end of the document. (If no arg has been given.) + """ + buf = event.current_buffer + return TextObject(buf.document.translate_row_col_to_index(buf.document.line_count - 1, 0) - + buf.cursor_position, type=TextObjectType.LINEWISE) + + # + # *** Other *** + # + + @handle('G', filter=HasArg()) + def _(event): + """ + If an argument is given, move to this line in the history. (for + example, 15G) + """ + event.current_buffer.go_to_history(event.arg - 1) + + for n in '123456789': + @handle(n, filter=navigation_mode|selection_mode|operator_given) + def _(event): + """ + Always handle numberics in navigation mode as arg. + """ + event.append_to_arg_count(event.data) + + @handle('0', filter=(navigation_mode|selection_mode|operator_given) & HasArg()) + def _(event): + " Zero when an argument was already give. " + event.append_to_arg_count(event.data) + + @handle(Keys.Any, filter=replace_mode) + def _(event): + """ + Insert data at cursor position. + """ + event.current_buffer.insert_text(event.data, overwrite=True) + + @handle(Keys.Any, filter=insert_multiple_mode, + save_before=(lambda e: not e.is_repeat)) + def _(event): + """ + Insert data at multiple cursor positions at once. + (Usually a result of pressing 'I' or 'A' in block-selection mode.) + """ + buff = event.current_buffer + original_text = buff.text + + # Construct new text. + text = [] + p = 0 + + for p2 in buff.multiple_cursor_positions: + text.append(original_text[p:p2]) + text.append(event.data) + p = p2 + + text.append(original_text[p:]) + + # Shift all cursor positions. + new_cursor_positions = [ + p + i + 1 for i, p in enumerate(buff.multiple_cursor_positions)] + + # Set result. + buff.text = ''.join(text) + buff.multiple_cursor_positions = new_cursor_positions + buff.cursor_position += 1 + + @handle(Keys.Backspace, filter=insert_multiple_mode) + def _(event): + " Backspace, using multiple cursors. " + buff = event.current_buffer + original_text = buff.text + + # Construct new text. + deleted_something = False + text = [] + p = 0 + + for p2 in buff.multiple_cursor_positions: + if p2 > 0 and original_text[p2 - 1] != '\n': # Don't delete across lines. + text.append(original_text[p:p2 - 1]) + deleted_something = True + else: + text.append(original_text[p:p2]) + p = p2 + + text.append(original_text[p:]) + + if deleted_something: + # Shift all cursor positions. + lengths = [len(part) for part in text[:-1]] + new_cursor_positions = list(accumulate(lengths)) + + # Set result. + buff.text = ''.join(text) + buff.multiple_cursor_positions = new_cursor_positions + buff.cursor_position -= 1 + else: + event.cli.output.bell() + + @handle(Keys.Delete, filter=insert_multiple_mode) + def _(event): + " Delete, using multiple cursors. " + buff = event.current_buffer + original_text = buff.text + + # Construct new text. + deleted_something = False + text = [] + new_cursor_positions = [] + p = 0 + + for p2 in buff.multiple_cursor_positions: + text.append(original_text[p:p2]) + if p2 >= len(original_text) or original_text[p2] == '\n': + # Don't delete across lines. + p = p2 + else: + p = p2 + 1 + deleted_something = True + + text.append(original_text[p:]) + + if deleted_something: + # Shift all cursor positions. + lengths = [len(part) for part in text[:-1]] + new_cursor_positions = list(accumulate(lengths)) + + # Set result. + buff.text = ''.join(text) + buff.multiple_cursor_positions = new_cursor_positions + else: + event.cli.output.bell() + + + @handle(Keys.ControlX, Keys.ControlL, filter=insert_mode) + def _(event): + """ + Pressing the ControlX - ControlL sequence in Vi mode does line + completion based on the other lines in the document and the history. + """ + event.current_buffer.start_history_lines_completion() + + @handle(Keys.ControlX, Keys.ControlF, filter=insert_mode) + def _(event): + """ + Complete file names. + """ + # TODO + pass + + @handle(Keys.ControlK, filter=insert_mode|replace_mode) + def _(event): + " Go into digraph mode. " + event.cli.vi_state.waiting_for_digraph = True + + @Condition + def digraph_symbol_1_given(cli): + return cli.vi_state.digraph_symbol1 is not None + + @handle(Keys.Any, filter=digraph_mode & ~digraph_symbol_1_given) + def _(event): + event.cli.vi_state.digraph_symbol1 = event.data + + @handle(Keys.Any, filter=digraph_mode & digraph_symbol_1_given) + def _(event): + " Insert digraph. " + try: + # Lookup. + code = (event.cli.vi_state.digraph_symbol1, event.data) + if code not in DIGRAPHS: + code = code[::-1] # Try reversing. + symbol = DIGRAPHS[code] + except KeyError: + # Unkown digraph. + event.cli.output.bell() + else: + # Insert digraph. + overwrite = event.cli.vi_state.input_mode == InputMode.REPLACE + event.current_buffer.insert_text( + six.unichr(symbol), overwrite=overwrite) + event.cli.vi_state.waiting_for_digraph = False + finally: + event.cli.vi_state.waiting_for_digraph = False + event.cli.vi_state.digraph_symbol1 = None + + return registry + + +def load_vi_open_in_editor_bindings(): + """ + Pressing 'v' in navigation mode will open the buffer in an external editor. + """ + registry = Registry() + navigation_mode = ViNavigationMode() + + registry.add_binding('v', filter=navigation_mode)( + get_by_name('edit-and-execute-command')) + return registry + + +def load_vi_system_bindings(): + registry = ConditionalRegistry(Registry(), ViMode()) + handle = registry.add_binding + + has_focus = filters.HasFocus(SYSTEM_BUFFER) + navigation_mode = ViNavigationMode() + + @handle('!', filter=~has_focus & navigation_mode) + def _(event): + """ + '!' opens the system prompt. + """ + event.cli.push_focus(SYSTEM_BUFFER) + event.cli.vi_state.input_mode = InputMode.INSERT + + @handle(Keys.Escape, filter=has_focus) + @handle(Keys.ControlC, filter=has_focus) + def _(event): + """ + Cancel system prompt. + """ + event.cli.vi_state.input_mode = InputMode.NAVIGATION + event.cli.buffers[SYSTEM_BUFFER].reset() + event.cli.pop_focus() + + @handle(Keys.ControlJ, filter=has_focus) + def _(event): + """ + Run system command. + """ + event.cli.vi_state.input_mode = InputMode.NAVIGATION + + system_buffer = event.cli.buffers[SYSTEM_BUFFER] + event.cli.run_system_command(system_buffer.text) + system_buffer.reset(append_to_history=True) + + # Focus previous buffer again. + event.cli.pop_focus() + + return registry + + +def load_vi_search_bindings(get_search_state=None, + search_buffer_name=SEARCH_BUFFER): + assert get_search_state is None or callable(get_search_state) + + if not get_search_state: + def get_search_state(cli): return cli.search_state + + registry = ConditionalRegistry(Registry(), ViMode()) + handle = registry.add_binding + + has_focus = filters.HasFocus(search_buffer_name) + navigation_mode = ViNavigationMode() + selection_mode = ViSelectionMode() + + reverse_vi_search_direction = Condition( + lambda cli: cli.application.reverse_vi_search_direction(cli)) + + @handle('/', filter=(navigation_mode|selection_mode)&~reverse_vi_search_direction) + @handle('?', filter=(navigation_mode|selection_mode)&reverse_vi_search_direction) + @handle(Keys.ControlS, filter=~has_focus) + def _(event): + """ + Vi-style forward search. + """ + # Set the ViState. + get_search_state(event.cli).direction = IncrementalSearchDirection.FORWARD + event.cli.vi_state.input_mode = InputMode.INSERT + + # Focus search buffer. + event.cli.push_focus(search_buffer_name) + + @handle('?', filter=(navigation_mode|selection_mode)&~reverse_vi_search_direction) + @handle('/', filter=(navigation_mode|selection_mode)&reverse_vi_search_direction) + @handle(Keys.ControlR, filter=~has_focus) + def _(event): + """ + Vi-style backward search. + """ + # Set the ViState. + get_search_state(event.cli).direction = IncrementalSearchDirection.BACKWARD + + # Focus search buffer. + event.cli.push_focus(search_buffer_name) + event.cli.vi_state.input_mode = InputMode.INSERT + + @handle(Keys.ControlJ, filter=has_focus) + def _(event): + """ + Apply the search. (At the / or ? prompt.) + """ + input_buffer = event.cli.buffers.previous(event.cli) + search_buffer = event.cli.buffers[search_buffer_name] + + # Update search state. + if search_buffer.text: + get_search_state(event.cli).text = search_buffer.text + + # Apply search. + input_buffer.apply_search(get_search_state(event.cli)) + + # Add query to history of search line. + search_buffer.append_to_history() + search_buffer.reset() + + # Focus previous document again. + event.cli.vi_state.input_mode = InputMode.NAVIGATION + event.cli.pop_focus() + + def incremental_search(cli, direction, count=1): + " Apply search, but keep search buffer focussed. " + # Update search_state. + search_state = get_search_state(cli) + direction_changed = search_state.direction != direction + + search_state.text = cli.buffers[search_buffer_name].text + search_state.direction = direction + + # Apply search to current buffer. + if not direction_changed: + input_buffer = cli.buffers.previous(cli) + input_buffer.apply_search(search_state, + include_current_position=False, count=count) + + @handle(Keys.ControlR, filter=has_focus) + def _(event): + incremental_search(event.cli, IncrementalSearchDirection.BACKWARD, count=event.arg) + + @handle(Keys.ControlS, filter=has_focus) + def _(event): + incremental_search(event.cli, IncrementalSearchDirection.FORWARD, count=event.arg) + + def search_buffer_is_empty(cli): + """ Returns True when the search buffer is empty. """ + return cli.buffers[search_buffer_name].text == '' + + @handle(Keys.Escape, filter=has_focus) + @handle(Keys.ControlC, filter=has_focus) + @handle(Keys.ControlH, filter=has_focus & Condition(search_buffer_is_empty)) + @handle(Keys.Backspace, filter=has_focus & Condition(search_buffer_is_empty)) + def _(event): + """ + Cancel search. + """ + event.cli.vi_state.input_mode = InputMode.NAVIGATION + + event.cli.pop_focus() + event.cli.buffers[search_buffer_name].reset() + + return registry + + +def load_extra_vi_page_navigation_bindings(): + """ + Key bindings, for scrolling up and down through pages. + This are separate bindings, because GNU readline doesn't have them. + """ + registry = ConditionalRegistry(Registry(), ViMode()) + handle = registry.add_binding + + handle(Keys.ControlF)(scroll_forward) + handle(Keys.ControlB)(scroll_backward) + handle(Keys.ControlD)(scroll_half_page_down) + handle(Keys.ControlU)(scroll_half_page_up) + handle(Keys.ControlE)(scroll_one_line_down) + handle(Keys.ControlY)(scroll_one_line_up) + handle(Keys.PageDown)(scroll_page_down) + handle(Keys.PageUp)(scroll_page_up) + + return registry + + +class ViStateFilter(Filter): + " Deprecated! " + def __init__(self, get_vi_state, mode): + self.get_vi_state = get_vi_state + self.mode = mode + + def __call__(self, cli): + return self.get_vi_state(cli).input_mode == self.mode diff --git a/src/libs/prompt_toolkit/key_binding/defaults.py b/src/libs/prompt_toolkit/key_binding/defaults.py new file mode 100644 index 0000000..4a1fb64 --- /dev/null +++ b/src/libs/prompt_toolkit/key_binding/defaults.py @@ -0,0 +1,119 @@ +""" +Default key bindings.:: + + registry = load_key_bindings() + app = Application(key_bindings_registry=registry) +""" +from __future__ import unicode_literals +from libs.prompt_toolkit.key_binding.registry import ConditionalRegistry, MergedRegistry +from libs.prompt_toolkit.key_binding.bindings.basic import load_basic_bindings, load_abort_and_exit_bindings, load_basic_system_bindings, load_auto_suggestion_bindings, load_mouse_bindings +from libs.prompt_toolkit.key_binding.bindings.emacs import load_emacs_bindings, load_emacs_system_bindings, load_emacs_search_bindings, load_emacs_open_in_editor_bindings, load_extra_emacs_page_navigation_bindings +from libs.prompt_toolkit.key_binding.bindings.vi import load_vi_bindings, load_vi_system_bindings, load_vi_search_bindings, load_vi_open_in_editor_bindings, load_extra_vi_page_navigation_bindings +from libs.prompt_toolkit.filters import to_cli_filter + +__all__ = ( + 'load_key_bindings', + 'load_key_bindings_for_prompt', +) + + +def load_key_bindings( + get_search_state=None, + enable_abort_and_exit_bindings=False, + enable_system_bindings=False, + enable_search=False, + enable_open_in_editor=False, + enable_extra_page_navigation=False, + enable_auto_suggest_bindings=False): + """ + Create a Registry object that contains the default key bindings. + + :param enable_abort_and_exit_bindings: Filter to enable Ctrl-C and Ctrl-D. + :param enable_system_bindings: Filter to enable the system bindings (meta-! + prompt and Control-Z suspension.) + :param enable_search: Filter to enable the search bindings. + :param enable_open_in_editor: Filter to enable open-in-editor. + :param enable_open_in_editor: Filter to enable open-in-editor. + :param enable_extra_page_navigation: Filter for enabling extra page + navigation. (Bindings for up/down scrolling through long pages, like in + Emacs or Vi.) + :param enable_auto_suggest_bindings: Filter to enable fish-style suggestions. + """ + + assert get_search_state is None or callable(get_search_state) + + # Accept both Filters and booleans as input. + enable_abort_and_exit_bindings = to_cli_filter(enable_abort_and_exit_bindings) + enable_system_bindings = to_cli_filter(enable_system_bindings) + enable_search = to_cli_filter(enable_search) + enable_open_in_editor = to_cli_filter(enable_open_in_editor) + enable_extra_page_navigation = to_cli_filter(enable_extra_page_navigation) + enable_auto_suggest_bindings = to_cli_filter(enable_auto_suggest_bindings) + + registry = MergedRegistry([ + # Load basic bindings. + load_basic_bindings(), + load_mouse_bindings(), + + ConditionalRegistry(load_abort_and_exit_bindings(), + enable_abort_and_exit_bindings), + + ConditionalRegistry(load_basic_system_bindings(), + enable_system_bindings), + + # Load emacs bindings. + load_emacs_bindings(), + + ConditionalRegistry(load_emacs_open_in_editor_bindings(), + enable_open_in_editor), + + ConditionalRegistry(load_emacs_search_bindings(get_search_state=get_search_state), + enable_search), + + ConditionalRegistry(load_emacs_system_bindings(), + enable_system_bindings), + + ConditionalRegistry(load_extra_emacs_page_navigation_bindings(), + enable_extra_page_navigation), + + # Load Vi bindings. + load_vi_bindings(get_search_state=get_search_state), + + ConditionalRegistry(load_vi_open_in_editor_bindings(), + enable_open_in_editor), + + ConditionalRegistry(load_vi_search_bindings(get_search_state=get_search_state), + enable_search), + + ConditionalRegistry(load_vi_system_bindings(), + enable_system_bindings), + + ConditionalRegistry(load_extra_vi_page_navigation_bindings(), + enable_extra_page_navigation), + + # Suggestion bindings. + # (This has to come at the end, because the Vi bindings also have an + # implementation for the "right arrow", but we really want the + # suggestion binding when a suggestion is available.) + ConditionalRegistry(load_auto_suggestion_bindings(), + enable_auto_suggest_bindings), + ]) + + return registry + + +def load_key_bindings_for_prompt(**kw): + """ + Create a ``Registry`` object with the defaults key bindings for an input + prompt. + + This activates the key bindings for abort/exit (Ctrl-C/Ctrl-D), + incremental search and auto suggestions. + + (Not for full screen applications.) + """ + kw.setdefault('enable_abort_and_exit_bindings', True) + kw.setdefault('enable_search', True) + kw.setdefault('enable_auto_suggest_bindings', True) + + return load_key_bindings(**kw) diff --git a/src/libs/prompt_toolkit/key_binding/digraphs.py b/src/libs/prompt_toolkit/key_binding/digraphs.py new file mode 100644 index 0000000..36c6b15 --- /dev/null +++ b/src/libs/prompt_toolkit/key_binding/digraphs.py @@ -0,0 +1,1378 @@ +# encoding: utf-8 +from __future__ import unicode_literals +""" +Vi Digraphs. +This is a list of special characters that can be inserted in Vi insert mode by +pressing Control-K followed by to normal characters. + +Taken from Neovim and translated to Python: +https://raw.githubusercontent.com/neovim/neovim/master/src/nvim/digraph.c +""" +__all__ = ('DIGRAPHS', ) + +# digraphs for Unicode from RFC1345 +# (also work for ISO-8859-1 aka latin1) +DIGRAPHS = { + ('N', 'U'): 0x00, + ('S', 'H'): 0x01, + ('S', 'X'): 0x02, + ('E', 'X'): 0x03, + ('E', 'T'): 0x04, + ('E', 'Q'): 0x05, + ('A', 'K'): 0x06, + ('B', 'L'): 0x07, + ('B', 'S'): 0x08, + ('H', 'T'): 0x09, + ('L', 'F'): 0x0a, + ('V', 'T'): 0x0b, + ('F', 'F'): 0x0c, + ('C', 'R'): 0x0d, + ('S', 'O'): 0x0e, + ('S', 'I'): 0x0f, + ('D', 'L'): 0x10, + ('D', '1'): 0x11, + ('D', '2'): 0x12, + ('D', '3'): 0x13, + ('D', '4'): 0x14, + ('N', 'K'): 0x15, + ('S', 'Y'): 0x16, + ('E', 'B'): 0x17, + ('C', 'N'): 0x18, + ('E', 'M'): 0x19, + ('S', 'B'): 0x1a, + ('E', 'C'): 0x1b, + ('F', 'S'): 0x1c, + ('G', 'S'): 0x1d, + ('R', 'S'): 0x1e, + ('U', 'S'): 0x1f, + ('S', 'P'): 0x20, + ('N', 'b'): 0x23, + ('D', 'O'): 0x24, + ('A', 't'): 0x40, + ('<', '('): 0x5b, + ('/', '/'): 0x5c, + (')', '>'): 0x5d, + ('\'', '>'): 0x5e, + ('\'', '!'): 0x60, + ('(', '!'): 0x7b, + ('!', '!'): 0x7c, + ('!', ')'): 0x7d, + ('\'', '?'): 0x7e, + ('D', 'T'): 0x7f, + ('P', 'A'): 0x80, + ('H', 'O'): 0x81, + ('B', 'H'): 0x82, + ('N', 'H'): 0x83, + ('I', 'N'): 0x84, + ('N', 'L'): 0x85, + ('S', 'A'): 0x86, + ('E', 'S'): 0x87, + ('H', 'S'): 0x88, + ('H', 'J'): 0x89, + ('V', 'S'): 0x8a, + ('P', 'D'): 0x8b, + ('P', 'U'): 0x8c, + ('R', 'I'): 0x8d, + ('S', '2'): 0x8e, + ('S', '3'): 0x8f, + ('D', 'C'): 0x90, + ('P', '1'): 0x91, + ('P', '2'): 0x92, + ('T', 'S'): 0x93, + ('C', 'C'): 0x94, + ('M', 'W'): 0x95, + ('S', 'G'): 0x96, + ('E', 'G'): 0x97, + ('S', 'S'): 0x98, + ('G', 'C'): 0x99, + ('S', 'C'): 0x9a, + ('C', 'I'): 0x9b, + ('S', 'T'): 0x9c, + ('O', 'C'): 0x9d, + ('P', 'M'): 0x9e, + ('A', 'C'): 0x9f, + ('N', 'S'): 0xa0, + ('!', 'I'): 0xa1, + ('C', 't'): 0xa2, + ('P', 'd'): 0xa3, + ('C', 'u'): 0xa4, + ('Y', 'e'): 0xa5, + ('B', 'B'): 0xa6, + ('S', 'E'): 0xa7, + ('\'', ':'): 0xa8, + ('C', 'o'): 0xa9, + ('-', 'a'): 0xaa, + ('<', '<'): 0xab, + ('N', 'O'): 0xac, + ('-', '-'): 0xad, + ('R', 'g'): 0xae, + ('\'', 'm'): 0xaf, + ('D', 'G'): 0xb0, + ('+', '-'): 0xb1, + ('2', 'S'): 0xb2, + ('3', 'S'): 0xb3, + ('\'', '\''): 0xb4, + ('M', 'y'): 0xb5, + ('P', 'I'): 0xb6, + ('.', 'M'): 0xb7, + ('\'', ','): 0xb8, + ('1', 'S'): 0xb9, + ('-', 'o'): 0xba, + ('>', '>'): 0xbb, + ('1', '4'): 0xbc, + ('1', '2'): 0xbd, + ('3', '4'): 0xbe, + ('?', 'I'): 0xbf, + ('A', '!'): 0xc0, + ('A', '\''): 0xc1, + ('A', '>'): 0xc2, + ('A', '?'): 0xc3, + ('A', ':'): 0xc4, + ('A', 'A'): 0xc5, + ('A', 'E'): 0xc6, + ('C', ','): 0xc7, + ('E', '!'): 0xc8, + ('E', '\''): 0xc9, + ('E', '>'): 0xca, + ('E', ':'): 0xcb, + ('I', '!'): 0xcc, + ('I', '\''): 0xcd, + ('I', '>'): 0xce, + ('I', ':'): 0xcf, + ('D', '-'): 0xd0, + ('N', '?'): 0xd1, + ('O', '!'): 0xd2, + ('O', '\''): 0xd3, + ('O', '>'): 0xd4, + ('O', '?'): 0xd5, + ('O', ':'): 0xd6, + ('*', 'X'): 0xd7, + ('O', '/'): 0xd8, + ('U', '!'): 0xd9, + ('U', '\''): 0xda, + ('U', '>'): 0xdb, + ('U', ':'): 0xdc, + ('Y', '\''): 0xdd, + ('T', 'H'): 0xde, + ('s', 's'): 0xdf, + ('a', '!'): 0xe0, + ('a', '\''): 0xe1, + ('a', '>'): 0xe2, + ('a', '?'): 0xe3, + ('a', ':'): 0xe4, + ('a', 'a'): 0xe5, + ('a', 'e'): 0xe6, + ('c', ','): 0xe7, + ('e', '!'): 0xe8, + ('e', '\''): 0xe9, + ('e', '>'): 0xea, + ('e', ':'): 0xeb, + ('i', '!'): 0xec, + ('i', '\''): 0xed, + ('i', '>'): 0xee, + ('i', ':'): 0xef, + ('d', '-'): 0xf0, + ('n', '?'): 0xf1, + ('o', '!'): 0xf2, + ('o', '\''): 0xf3, + ('o', '>'): 0xf4, + ('o', '?'): 0xf5, + ('o', ':'): 0xf6, + ('-', ':'): 0xf7, + ('o', '/'): 0xf8, + ('u', '!'): 0xf9, + ('u', '\''): 0xfa, + ('u', '>'): 0xfb, + ('u', ':'): 0xfc, + ('y', '\''): 0xfd, + ('t', 'h'): 0xfe, + ('y', ':'): 0xff, + + ('A', '-'): 0x0100, + ('a', '-'): 0x0101, + ('A', '('): 0x0102, + ('a', '('): 0x0103, + ('A', ';'): 0x0104, + ('a', ';'): 0x0105, + ('C', '\''): 0x0106, + ('c', '\''): 0x0107, + ('C', '>'): 0x0108, + ('c', '>'): 0x0109, + ('C', '.'): 0x010a, + ('c', '.'): 0x010b, + ('C', '<'): 0x010c, + ('c', '<'): 0x010d, + ('D', '<'): 0x010e, + ('d', '<'): 0x010f, + ('D', '/'): 0x0110, + ('d', '/'): 0x0111, + ('E', '-'): 0x0112, + ('e', '-'): 0x0113, + ('E', '('): 0x0114, + ('e', '('): 0x0115, + ('E', '.'): 0x0116, + ('e', '.'): 0x0117, + ('E', ';'): 0x0118, + ('e', ';'): 0x0119, + ('E', '<'): 0x011a, + ('e', '<'): 0x011b, + ('G', '>'): 0x011c, + ('g', '>'): 0x011d, + ('G', '('): 0x011e, + ('g', '('): 0x011f, + ('G', '.'): 0x0120, + ('g', '.'): 0x0121, + ('G', ','): 0x0122, + ('g', ','): 0x0123, + ('H', '>'): 0x0124, + ('h', '>'): 0x0125, + ('H', '/'): 0x0126, + ('h', '/'): 0x0127, + ('I', '?'): 0x0128, + ('i', '?'): 0x0129, + ('I', '-'): 0x012a, + ('i', '-'): 0x012b, + ('I', '('): 0x012c, + ('i', '('): 0x012d, + ('I', ';'): 0x012e, + ('i', ';'): 0x012f, + ('I', '.'): 0x0130, + ('i', '.'): 0x0131, + ('I', 'J'): 0x0132, + ('i', 'j'): 0x0133, + ('J', '>'): 0x0134, + ('j', '>'): 0x0135, + ('K', ','): 0x0136, + ('k', ','): 0x0137, + ('k', 'k'): 0x0138, + ('L', '\''): 0x0139, + ('l', '\''): 0x013a, + ('L', ','): 0x013b, + ('l', ','): 0x013c, + ('L', '<'): 0x013d, + ('l', '<'): 0x013e, + ('L', '.'): 0x013f, + ('l', '.'): 0x0140, + ('L', '/'): 0x0141, + ('l', '/'): 0x0142, + ('N', '\''): 0x0143, + ('n', '\''): 0x0144, + ('N', ','): 0x0145, + ('n', ','): 0x0146, + ('N', '<'): 0x0147, + ('n', '<'): 0x0148, + ('\'', 'n'): 0x0149, + ('N', 'G'): 0x014a, + ('n', 'g'): 0x014b, + ('O', '-'): 0x014c, + ('o', '-'): 0x014d, + ('O', '('): 0x014e, + ('o', '('): 0x014f, + ('O', '"'): 0x0150, + ('o', '"'): 0x0151, + ('O', 'E'): 0x0152, + ('o', 'e'): 0x0153, + ('R', '\''): 0x0154, + ('r', '\''): 0x0155, + ('R', ','): 0x0156, + ('r', ','): 0x0157, + ('R', '<'): 0x0158, + ('r', '<'): 0x0159, + ('S', '\''): 0x015a, + ('s', '\''): 0x015b, + ('S', '>'): 0x015c, + ('s', '>'): 0x015d, + ('S', ','): 0x015e, + ('s', ','): 0x015f, + ('S', '<'): 0x0160, + ('s', '<'): 0x0161, + ('T', ','): 0x0162, + ('t', ','): 0x0163, + ('T', '<'): 0x0164, + ('t', '<'): 0x0165, + ('T', '/'): 0x0166, + ('t', '/'): 0x0167, + ('U', '?'): 0x0168, + ('u', '?'): 0x0169, + ('U', '-'): 0x016a, + ('u', '-'): 0x016b, + ('U', '('): 0x016c, + ('u', '('): 0x016d, + ('U', '0'): 0x016e, + ('u', '0'): 0x016f, + ('U', '"'): 0x0170, + ('u', '"'): 0x0171, + ('U', ';'): 0x0172, + ('u', ';'): 0x0173, + ('W', '>'): 0x0174, + ('w', '>'): 0x0175, + ('Y', '>'): 0x0176, + ('y', '>'): 0x0177, + ('Y', ':'): 0x0178, + ('Z', '\''): 0x0179, + ('z', '\''): 0x017a, + ('Z', '.'): 0x017b, + ('z', '.'): 0x017c, + ('Z', '<'): 0x017d, + ('z', '<'): 0x017e, + ('O', '9'): 0x01a0, + ('o', '9'): 0x01a1, + ('O', 'I'): 0x01a2, + ('o', 'i'): 0x01a3, + ('y', 'r'): 0x01a6, + ('U', '9'): 0x01af, + ('u', '9'): 0x01b0, + ('Z', '/'): 0x01b5, + ('z', '/'): 0x01b6, + ('E', 'D'): 0x01b7, + ('A', '<'): 0x01cd, + ('a', '<'): 0x01ce, + ('I', '<'): 0x01cf, + ('i', '<'): 0x01d0, + ('O', '<'): 0x01d1, + ('o', '<'): 0x01d2, + ('U', '<'): 0x01d3, + ('u', '<'): 0x01d4, + ('A', '1'): 0x01de, + ('a', '1'): 0x01df, + ('A', '7'): 0x01e0, + ('a', '7'): 0x01e1, + ('A', '3'): 0x01e2, + ('a', '3'): 0x01e3, + ('G', '/'): 0x01e4, + ('g', '/'): 0x01e5, + ('G', '<'): 0x01e6, + ('g', '<'): 0x01e7, + ('K', '<'): 0x01e8, + ('k', '<'): 0x01e9, + ('O', ';'): 0x01ea, + ('o', ';'): 0x01eb, + ('O', '1'): 0x01ec, + ('o', '1'): 0x01ed, + ('E', 'Z'): 0x01ee, + ('e', 'z'): 0x01ef, + ('j', '<'): 0x01f0, + ('G', '\''): 0x01f4, + ('g', '\''): 0x01f5, + (';', 'S'): 0x02bf, + ('\'', '<'): 0x02c7, + ('\'', '('): 0x02d8, + ('\'', '.'): 0x02d9, + ('\'', '0'): 0x02da, + ('\'', ';'): 0x02db, + ('\'', '"'): 0x02dd, + ('A', '%'): 0x0386, + ('E', '%'): 0x0388, + ('Y', '%'): 0x0389, + ('I', '%'): 0x038a, + ('O', '%'): 0x038c, + ('U', '%'): 0x038e, + ('W', '%'): 0x038f, + ('i', '3'): 0x0390, + ('A', '*'): 0x0391, + ('B', '*'): 0x0392, + ('G', '*'): 0x0393, + ('D', '*'): 0x0394, + ('E', '*'): 0x0395, + ('Z', '*'): 0x0396, + ('Y', '*'): 0x0397, + ('H', '*'): 0x0398, + ('I', '*'): 0x0399, + ('K', '*'): 0x039a, + ('L', '*'): 0x039b, + ('M', '*'): 0x039c, + ('N', '*'): 0x039d, + ('C', '*'): 0x039e, + ('O', '*'): 0x039f, + ('P', '*'): 0x03a0, + ('R', '*'): 0x03a1, + ('S', '*'): 0x03a3, + ('T', '*'): 0x03a4, + ('U', '*'): 0x03a5, + ('F', '*'): 0x03a6, + ('X', '*'): 0x03a7, + ('Q', '*'): 0x03a8, + ('W', '*'): 0x03a9, + ('J', '*'): 0x03aa, + ('V', '*'): 0x03ab, + ('a', '%'): 0x03ac, + ('e', '%'): 0x03ad, + ('y', '%'): 0x03ae, + ('i', '%'): 0x03af, + ('u', '3'): 0x03b0, + ('a', '*'): 0x03b1, + ('b', '*'): 0x03b2, + ('g', '*'): 0x03b3, + ('d', '*'): 0x03b4, + ('e', '*'): 0x03b5, + ('z', '*'): 0x03b6, + ('y', '*'): 0x03b7, + ('h', '*'): 0x03b8, + ('i', '*'): 0x03b9, + ('k', '*'): 0x03ba, + ('l', '*'): 0x03bb, + ('m', '*'): 0x03bc, + ('n', '*'): 0x03bd, + ('c', '*'): 0x03be, + ('o', '*'): 0x03bf, + ('p', '*'): 0x03c0, + ('r', '*'): 0x03c1, + ('*', 's'): 0x03c2, + ('s', '*'): 0x03c3, + ('t', '*'): 0x03c4, + ('u', '*'): 0x03c5, + ('f', '*'): 0x03c6, + ('x', '*'): 0x03c7, + ('q', '*'): 0x03c8, + ('w', '*'): 0x03c9, + ('j', '*'): 0x03ca, + ('v', '*'): 0x03cb, + ('o', '%'): 0x03cc, + ('u', '%'): 0x03cd, + ('w', '%'): 0x03ce, + ('\'', 'G'): 0x03d8, + (',', 'G'): 0x03d9, + ('T', '3'): 0x03da, + ('t', '3'): 0x03db, + ('M', '3'): 0x03dc, + ('m', '3'): 0x03dd, + ('K', '3'): 0x03de, + ('k', '3'): 0x03df, + ('P', '3'): 0x03e0, + ('p', '3'): 0x03e1, + ('\'', '%'): 0x03f4, + ('j', '3'): 0x03f5, + ('I', 'O'): 0x0401, + ('D', '%'): 0x0402, + ('G', '%'): 0x0403, + ('I', 'E'): 0x0404, + ('D', 'S'): 0x0405, + ('I', 'I'): 0x0406, + ('Y', 'I'): 0x0407, + ('J', '%'): 0x0408, + ('L', 'J'): 0x0409, + ('N', 'J'): 0x040a, + ('T', 's'): 0x040b, + ('K', 'J'): 0x040c, + ('V', '%'): 0x040e, + ('D', 'Z'): 0x040f, + ('A', '='): 0x0410, + ('B', '='): 0x0411, + ('V', '='): 0x0412, + ('G', '='): 0x0413, + ('D', '='): 0x0414, + ('E', '='): 0x0415, + ('Z', '%'): 0x0416, + ('Z', '='): 0x0417, + ('I', '='): 0x0418, + ('J', '='): 0x0419, + ('K', '='): 0x041a, + ('L', '='): 0x041b, + ('M', '='): 0x041c, + ('N', '='): 0x041d, + ('O', '='): 0x041e, + ('P', '='): 0x041f, + ('R', '='): 0x0420, + ('S', '='): 0x0421, + ('T', '='): 0x0422, + ('U', '='): 0x0423, + ('F', '='): 0x0424, + ('H', '='): 0x0425, + ('C', '='): 0x0426, + ('C', '%'): 0x0427, + ('S', '%'): 0x0428, + ('S', 'c'): 0x0429, + ('=', '"'): 0x042a, + ('Y', '='): 0x042b, + ('%', '"'): 0x042c, + ('J', 'E'): 0x042d, + ('J', 'U'): 0x042e, + ('J', 'A'): 0x042f, + ('a', '='): 0x0430, + ('b', '='): 0x0431, + ('v', '='): 0x0432, + ('g', '='): 0x0433, + ('d', '='): 0x0434, + ('e', '='): 0x0435, + ('z', '%'): 0x0436, + ('z', '='): 0x0437, + ('i', '='): 0x0438, + ('j', '='): 0x0439, + ('k', '='): 0x043a, + ('l', '='): 0x043b, + ('m', '='): 0x043c, + ('n', '='): 0x043d, + ('o', '='): 0x043e, + ('p', '='): 0x043f, + ('r', '='): 0x0440, + ('s', '='): 0x0441, + ('t', '='): 0x0442, + ('u', '='): 0x0443, + ('f', '='): 0x0444, + ('h', '='): 0x0445, + ('c', '='): 0x0446, + ('c', '%'): 0x0447, + ('s', '%'): 0x0448, + ('s', 'c'): 0x0449, + ('=', '\''): 0x044a, + ('y', '='): 0x044b, + ('%', '\''): 0x044c, + ('j', 'e'): 0x044d, + ('j', 'u'): 0x044e, + ('j', 'a'): 0x044f, + ('i', 'o'): 0x0451, + ('d', '%'): 0x0452, + ('g', '%'): 0x0453, + ('i', 'e'): 0x0454, + ('d', 's'): 0x0455, + ('i', 'i'): 0x0456, + ('y', 'i'): 0x0457, + ('j', '%'): 0x0458, + ('l', 'j'): 0x0459, + ('n', 'j'): 0x045a, + ('t', 's'): 0x045b, + ('k', 'j'): 0x045c, + ('v', '%'): 0x045e, + ('d', 'z'): 0x045f, + ('Y', '3'): 0x0462, + ('y', '3'): 0x0463, + ('O', '3'): 0x046a, + ('o', '3'): 0x046b, + ('F', '3'): 0x0472, + ('f', '3'): 0x0473, + ('V', '3'): 0x0474, + ('v', '3'): 0x0475, + ('C', '3'): 0x0480, + ('c', '3'): 0x0481, + ('G', '3'): 0x0490, + ('g', '3'): 0x0491, + ('A', '+'): 0x05d0, + ('B', '+'): 0x05d1, + ('G', '+'): 0x05d2, + ('D', '+'): 0x05d3, + ('H', '+'): 0x05d4, + ('W', '+'): 0x05d5, + ('Z', '+'): 0x05d6, + ('X', '+'): 0x05d7, + ('T', 'j'): 0x05d8, + ('J', '+'): 0x05d9, + ('K', '%'): 0x05da, + ('K', '+'): 0x05db, + ('L', '+'): 0x05dc, + ('M', '%'): 0x05dd, + ('M', '+'): 0x05de, + ('N', '%'): 0x05df, + ('N', '+'): 0x05e0, + ('S', '+'): 0x05e1, + ('E', '+'): 0x05e2, + ('P', '%'): 0x05e3, + ('P', '+'): 0x05e4, + ('Z', 'j'): 0x05e5, + ('Z', 'J'): 0x05e6, + ('Q', '+'): 0x05e7, + ('R', '+'): 0x05e8, + ('S', 'h'): 0x05e9, + ('T', '+'): 0x05ea, + (',', '+'): 0x060c, + (';', '+'): 0x061b, + ('?', '+'): 0x061f, + ('H', '\''): 0x0621, + ('a', 'M'): 0x0622, + ('a', 'H'): 0x0623, + ('w', 'H'): 0x0624, + ('a', 'h'): 0x0625, + ('y', 'H'): 0x0626, + ('a', '+'): 0x0627, + ('b', '+'): 0x0628, + ('t', 'm'): 0x0629, + ('t', '+'): 0x062a, + ('t', 'k'): 0x062b, + ('g', '+'): 0x062c, + ('h', 'k'): 0x062d, + ('x', '+'): 0x062e, + ('d', '+'): 0x062f, + ('d', 'k'): 0x0630, + ('r', '+'): 0x0631, + ('z', '+'): 0x0632, + ('s', '+'): 0x0633, + ('s', 'n'): 0x0634, + ('c', '+'): 0x0635, + ('d', 'd'): 0x0636, + ('t', 'j'): 0x0637, + ('z', 'H'): 0x0638, + ('e', '+'): 0x0639, + ('i', '+'): 0x063a, + ('+', '+'): 0x0640, + ('f', '+'): 0x0641, + ('q', '+'): 0x0642, + ('k', '+'): 0x0643, + ('l', '+'): 0x0644, + ('m', '+'): 0x0645, + ('n', '+'): 0x0646, + ('h', '+'): 0x0647, + ('w', '+'): 0x0648, + ('j', '+'): 0x0649, + ('y', '+'): 0x064a, + (':', '+'): 0x064b, + ('"', '+'): 0x064c, + ('=', '+'): 0x064d, + ('/', '+'): 0x064e, + ('\'', '+'): 0x064f, + ('1', '+'): 0x0650, + ('3', '+'): 0x0651, + ('0', '+'): 0x0652, + ('a', 'S'): 0x0670, + ('p', '+'): 0x067e, + ('v', '+'): 0x06a4, + ('g', 'f'): 0x06af, + ('0', 'a'): 0x06f0, + ('1', 'a'): 0x06f1, + ('2', 'a'): 0x06f2, + ('3', 'a'): 0x06f3, + ('4', 'a'): 0x06f4, + ('5', 'a'): 0x06f5, + ('6', 'a'): 0x06f6, + ('7', 'a'): 0x06f7, + ('8', 'a'): 0x06f8, + ('9', 'a'): 0x06f9, + ('B', '.'): 0x1e02, + ('b', '.'): 0x1e03, + ('B', '_'): 0x1e06, + ('b', '_'): 0x1e07, + ('D', '.'): 0x1e0a, + ('d', '.'): 0x1e0b, + ('D', '_'): 0x1e0e, + ('d', '_'): 0x1e0f, + ('D', ','): 0x1e10, + ('d', ','): 0x1e11, + ('F', '.'): 0x1e1e, + ('f', '.'): 0x1e1f, + ('G', '-'): 0x1e20, + ('g', '-'): 0x1e21, + ('H', '.'): 0x1e22, + ('h', '.'): 0x1e23, + ('H', ':'): 0x1e26, + ('h', ':'): 0x1e27, + ('H', ','): 0x1e28, + ('h', ','): 0x1e29, + ('K', '\''): 0x1e30, + ('k', '\''): 0x1e31, + ('K', '_'): 0x1e34, + ('k', '_'): 0x1e35, + ('L', '_'): 0x1e3a, + ('l', '_'): 0x1e3b, + ('M', '\''): 0x1e3e, + ('m', '\''): 0x1e3f, + ('M', '.'): 0x1e40, + ('m', '.'): 0x1e41, + ('N', '.'): 0x1e44, + ('n', '.'): 0x1e45, + ('N', '_'): 0x1e48, + ('n', '_'): 0x1e49, + ('P', '\''): 0x1e54, + ('p', '\''): 0x1e55, + ('P', '.'): 0x1e56, + ('p', '.'): 0x1e57, + ('R', '.'): 0x1e58, + ('r', '.'): 0x1e59, + ('R', '_'): 0x1e5e, + ('r', '_'): 0x1e5f, + ('S', '.'): 0x1e60, + ('s', '.'): 0x1e61, + ('T', '.'): 0x1e6a, + ('t', '.'): 0x1e6b, + ('T', '_'): 0x1e6e, + ('t', '_'): 0x1e6f, + ('V', '?'): 0x1e7c, + ('v', '?'): 0x1e7d, + ('W', '!'): 0x1e80, + ('w', '!'): 0x1e81, + ('W', '\''): 0x1e82, + ('w', '\''): 0x1e83, + ('W', ':'): 0x1e84, + ('w', ':'): 0x1e85, + ('W', '.'): 0x1e86, + ('w', '.'): 0x1e87, + ('X', '.'): 0x1e8a, + ('x', '.'): 0x1e8b, + ('X', ':'): 0x1e8c, + ('x', ':'): 0x1e8d, + ('Y', '.'): 0x1e8e, + ('y', '.'): 0x1e8f, + ('Z', '>'): 0x1e90, + ('z', '>'): 0x1e91, + ('Z', '_'): 0x1e94, + ('z', '_'): 0x1e95, + ('h', '_'): 0x1e96, + ('t', ':'): 0x1e97, + ('w', '0'): 0x1e98, + ('y', '0'): 0x1e99, + ('A', '2'): 0x1ea2, + ('a', '2'): 0x1ea3, + ('E', '2'): 0x1eba, + ('e', '2'): 0x1ebb, + ('E', '?'): 0x1ebc, + ('e', '?'): 0x1ebd, + ('I', '2'): 0x1ec8, + ('i', '2'): 0x1ec9, + ('O', '2'): 0x1ece, + ('o', '2'): 0x1ecf, + ('U', '2'): 0x1ee6, + ('u', '2'): 0x1ee7, + ('Y', '!'): 0x1ef2, + ('y', '!'): 0x1ef3, + ('Y', '2'): 0x1ef6, + ('y', '2'): 0x1ef7, + ('Y', '?'): 0x1ef8, + ('y', '?'): 0x1ef9, + (';', '\''): 0x1f00, + (',', '\''): 0x1f01, + (';', '!'): 0x1f02, + (',', '!'): 0x1f03, + ('?', ';'): 0x1f04, + ('?', ','): 0x1f05, + ('!', ':'): 0x1f06, + ('?', ':'): 0x1f07, + ('1', 'N'): 0x2002, + ('1', 'M'): 0x2003, + ('3', 'M'): 0x2004, + ('4', 'M'): 0x2005, + ('6', 'M'): 0x2006, + ('1', 'T'): 0x2009, + ('1', 'H'): 0x200a, + ('-', '1'): 0x2010, + ('-', 'N'): 0x2013, + ('-', 'M'): 0x2014, + ('-', '3'): 0x2015, + ('!', '2'): 0x2016, + ('=', '2'): 0x2017, + ('\'', '6'): 0x2018, + ('\'', '9'): 0x2019, + ('.', '9'): 0x201a, + ('9', '\''): 0x201b, + ('"', '6'): 0x201c, + ('"', '9'): 0x201d, + (':', '9'): 0x201e, + ('9', '"'): 0x201f, + ('/', '-'): 0x2020, + ('/', '='): 0x2021, + ('.', '.'): 0x2025, + ('%', '0'): 0x2030, + ('1', '\''): 0x2032, + ('2', '\''): 0x2033, + ('3', '\''): 0x2034, + ('1', '"'): 0x2035, + ('2', '"'): 0x2036, + ('3', '"'): 0x2037, + ('C', 'a'): 0x2038, + ('<', '1'): 0x2039, + ('>', '1'): 0x203a, + (':', 'X'): 0x203b, + ('\'', '-'): 0x203e, + ('/', 'f'): 0x2044, + ('0', 'S'): 0x2070, + ('4', 'S'): 0x2074, + ('5', 'S'): 0x2075, + ('6', 'S'): 0x2076, + ('7', 'S'): 0x2077, + ('8', 'S'): 0x2078, + ('9', 'S'): 0x2079, + ('+', 'S'): 0x207a, + ('-', 'S'): 0x207b, + ('=', 'S'): 0x207c, + ('(', 'S'): 0x207d, + (')', 'S'): 0x207e, + ('n', 'S'): 0x207f, + ('0', 's'): 0x2080, + ('1', 's'): 0x2081, + ('2', 's'): 0x2082, + ('3', 's'): 0x2083, + ('4', 's'): 0x2084, + ('5', 's'): 0x2085, + ('6', 's'): 0x2086, + ('7', 's'): 0x2087, + ('8', 's'): 0x2088, + ('9', 's'): 0x2089, + ('+', 's'): 0x208a, + ('-', 's'): 0x208b, + ('=', 's'): 0x208c, + ('(', 's'): 0x208d, + (')', 's'): 0x208e, + ('L', 'i'): 0x20a4, + ('P', 't'): 0x20a7, + ('W', '='): 0x20a9, + ('=', 'e'): 0x20ac, # euro + ('E', 'u'): 0x20ac, # euro + ('=', 'R'): 0x20bd, # rouble + ('=', 'P'): 0x20bd, # rouble + ('o', 'C'): 0x2103, + ('c', 'o'): 0x2105, + ('o', 'F'): 0x2109, + ('N', '0'): 0x2116, + ('P', 'O'): 0x2117, + ('R', 'x'): 0x211e, + ('S', 'M'): 0x2120, + ('T', 'M'): 0x2122, + ('O', 'm'): 0x2126, + ('A', 'O'): 0x212b, + ('1', '3'): 0x2153, + ('2', '3'): 0x2154, + ('1', '5'): 0x2155, + ('2', '5'): 0x2156, + ('3', '5'): 0x2157, + ('4', '5'): 0x2158, + ('1', '6'): 0x2159, + ('5', '6'): 0x215a, + ('1', '8'): 0x215b, + ('3', '8'): 0x215c, + ('5', '8'): 0x215d, + ('7', '8'): 0x215e, + ('1', 'R'): 0x2160, + ('2', 'R'): 0x2161, + ('3', 'R'): 0x2162, + ('4', 'R'): 0x2163, + ('5', 'R'): 0x2164, + ('6', 'R'): 0x2165, + ('7', 'R'): 0x2166, + ('8', 'R'): 0x2167, + ('9', 'R'): 0x2168, + ('a', 'R'): 0x2169, + ('b', 'R'): 0x216a, + ('c', 'R'): 0x216b, + ('1', 'r'): 0x2170, + ('2', 'r'): 0x2171, + ('3', 'r'): 0x2172, + ('4', 'r'): 0x2173, + ('5', 'r'): 0x2174, + ('6', 'r'): 0x2175, + ('7', 'r'): 0x2176, + ('8', 'r'): 0x2177, + ('9', 'r'): 0x2178, + ('a', 'r'): 0x2179, + ('b', 'r'): 0x217a, + ('c', 'r'): 0x217b, + ('<', '-'): 0x2190, + ('-', '!'): 0x2191, + ('-', '>'): 0x2192, + ('-', 'v'): 0x2193, + ('<', '>'): 0x2194, + ('U', 'D'): 0x2195, + ('<', '='): 0x21d0, + ('=', '>'): 0x21d2, + ('=', '='): 0x21d4, + ('F', 'A'): 0x2200, + ('d', 'P'): 0x2202, + ('T', 'E'): 0x2203, + ('/', '0'): 0x2205, + ('D', 'E'): 0x2206, + ('N', 'B'): 0x2207, + ('(', '-'): 0x2208, + ('-', ')'): 0x220b, + ('*', 'P'): 0x220f, + ('+', 'Z'): 0x2211, + ('-', '2'): 0x2212, + ('-', '+'): 0x2213, + ('*', '-'): 0x2217, + ('O', 'b'): 0x2218, + ('S', 'b'): 0x2219, + ('R', 'T'): 0x221a, + ('0', '('): 0x221d, + ('0', '0'): 0x221e, + ('-', 'L'): 0x221f, + ('-', 'V'): 0x2220, + ('P', 'P'): 0x2225, + ('A', 'N'): 0x2227, + ('O', 'R'): 0x2228, + ('(', 'U'): 0x2229, + (')', 'U'): 0x222a, + ('I', 'n'): 0x222b, + ('D', 'I'): 0x222c, + ('I', 'o'): 0x222e, + ('.', ':'): 0x2234, + (':', '.'): 0x2235, + (':', 'R'): 0x2236, + (':', ':'): 0x2237, + ('?', '1'): 0x223c, + ('C', 'G'): 0x223e, + ('?', '-'): 0x2243, + ('?', '='): 0x2245, + ('?', '2'): 0x2248, + ('=', '?'): 0x224c, + ('H', 'I'): 0x2253, + ('!', '='): 0x2260, + ('=', '3'): 0x2261, + ('=', '<'): 0x2264, + ('>', '='): 0x2265, + ('<', '*'): 0x226a, + ('*', '>'): 0x226b, + ('!', '<'): 0x226e, + ('!', '>'): 0x226f, + ('(', 'C'): 0x2282, + (')', 'C'): 0x2283, + ('(', '_'): 0x2286, + (')', '_'): 0x2287, + ('0', '.'): 0x2299, + ('0', '2'): 0x229a, + ('-', 'T'): 0x22a5, + ('.', 'P'): 0x22c5, + (':', '3'): 0x22ee, + ('.', '3'): 0x22ef, + ('E', 'h'): 0x2302, + ('<', '7'): 0x2308, + ('>', '7'): 0x2309, + ('7', '<'): 0x230a, + ('7', '>'): 0x230b, + ('N', 'I'): 0x2310, + ('(', 'A'): 0x2312, + ('T', 'R'): 0x2315, + ('I', 'u'): 0x2320, + ('I', 'l'): 0x2321, + ('<', '/'): 0x2329, + ('/', '>'): 0x232a, + ('V', 's'): 0x2423, + ('1', 'h'): 0x2440, + ('3', 'h'): 0x2441, + ('2', 'h'): 0x2442, + ('4', 'h'): 0x2443, + ('1', 'j'): 0x2446, + ('2', 'j'): 0x2447, + ('3', 'j'): 0x2448, + ('4', 'j'): 0x2449, + ('1', '.'): 0x2488, + ('2', '.'): 0x2489, + ('3', '.'): 0x248a, + ('4', '.'): 0x248b, + ('5', '.'): 0x248c, + ('6', '.'): 0x248d, + ('7', '.'): 0x248e, + ('8', '.'): 0x248f, + ('9', '.'): 0x2490, + ('h', 'h'): 0x2500, + ('H', 'H'): 0x2501, + ('v', 'v'): 0x2502, + ('V', 'V'): 0x2503, + ('3', '-'): 0x2504, + ('3', '_'): 0x2505, + ('3', '!'): 0x2506, + ('3', '/'): 0x2507, + ('4', '-'): 0x2508, + ('4', '_'): 0x2509, + ('4', '!'): 0x250a, + ('4', '/'): 0x250b, + ('d', 'r'): 0x250c, + ('d', 'R'): 0x250d, + ('D', 'r'): 0x250e, + ('D', 'R'): 0x250f, + ('d', 'l'): 0x2510, + ('d', 'L'): 0x2511, + ('D', 'l'): 0x2512, + ('L', 'D'): 0x2513, + ('u', 'r'): 0x2514, + ('u', 'R'): 0x2515, + ('U', 'r'): 0x2516, + ('U', 'R'): 0x2517, + ('u', 'l'): 0x2518, + ('u', 'L'): 0x2519, + ('U', 'l'): 0x251a, + ('U', 'L'): 0x251b, + ('v', 'r'): 0x251c, + ('v', 'R'): 0x251d, + ('V', 'r'): 0x2520, + ('V', 'R'): 0x2523, + ('v', 'l'): 0x2524, + ('v', 'L'): 0x2525, + ('V', 'l'): 0x2528, + ('V', 'L'): 0x252b, + ('d', 'h'): 0x252c, + ('d', 'H'): 0x252f, + ('D', 'h'): 0x2530, + ('D', 'H'): 0x2533, + ('u', 'h'): 0x2534, + ('u', 'H'): 0x2537, + ('U', 'h'): 0x2538, + ('U', 'H'): 0x253b, + ('v', 'h'): 0x253c, + ('v', 'H'): 0x253f, + ('V', 'h'): 0x2542, + ('V', 'H'): 0x254b, + ('F', 'D'): 0x2571, + ('B', 'D'): 0x2572, + ('T', 'B'): 0x2580, + ('L', 'B'): 0x2584, + ('F', 'B'): 0x2588, + ('l', 'B'): 0x258c, + ('R', 'B'): 0x2590, + ('.', 'S'): 0x2591, + (':', 'S'): 0x2592, + ('?', 'S'): 0x2593, + ('f', 'S'): 0x25a0, + ('O', 'S'): 0x25a1, + ('R', 'O'): 0x25a2, + ('R', 'r'): 0x25a3, + ('R', 'F'): 0x25a4, + ('R', 'Y'): 0x25a5, + ('R', 'H'): 0x25a6, + ('R', 'Z'): 0x25a7, + ('R', 'K'): 0x25a8, + ('R', 'X'): 0x25a9, + ('s', 'B'): 0x25aa, + ('S', 'R'): 0x25ac, + ('O', 'r'): 0x25ad, + ('U', 'T'): 0x25b2, + ('u', 'T'): 0x25b3, + ('P', 'R'): 0x25b6, + ('T', 'r'): 0x25b7, + ('D', 't'): 0x25bc, + ('d', 'T'): 0x25bd, + ('P', 'L'): 0x25c0, + ('T', 'l'): 0x25c1, + ('D', 'b'): 0x25c6, + ('D', 'w'): 0x25c7, + ('L', 'Z'): 0x25ca, + ('0', 'm'): 0x25cb, + ('0', 'o'): 0x25ce, + ('0', 'M'): 0x25cf, + ('0', 'L'): 0x25d0, + ('0', 'R'): 0x25d1, + ('S', 'n'): 0x25d8, + ('I', 'c'): 0x25d9, + ('F', 'd'): 0x25e2, + ('B', 'd'): 0x25e3, + ('*', '2'): 0x2605, + ('*', '1'): 0x2606, + ('<', 'H'): 0x261c, + ('>', 'H'): 0x261e, + ('0', 'u'): 0x263a, + ('0', 'U'): 0x263b, + ('S', 'U'): 0x263c, + ('F', 'm'): 0x2640, + ('M', 'l'): 0x2642, + ('c', 'S'): 0x2660, + ('c', 'H'): 0x2661, + ('c', 'D'): 0x2662, + ('c', 'C'): 0x2663, + ('M', 'd'): 0x2669, + ('M', '8'): 0x266a, + ('M', '2'): 0x266b, + ('M', 'b'): 0x266d, + ('M', 'x'): 0x266e, + ('M', 'X'): 0x266f, + ('O', 'K'): 0x2713, + ('X', 'X'): 0x2717, + ('-', 'X'): 0x2720, + ('I', 'S'): 0x3000, + (',', '_'): 0x3001, + ('.', '_'): 0x3002, + ('+', '"'): 0x3003, + ('+', '_'): 0x3004, + ('*', '_'): 0x3005, + (';', '_'): 0x3006, + ('0', '_'): 0x3007, + ('<', '+'): 0x300a, + ('>', '+'): 0x300b, + ('<', '\''): 0x300c, + ('>', '\''): 0x300d, + ('<', '"'): 0x300e, + ('>', '"'): 0x300f, + ('(', '"'): 0x3010, + (')', '"'): 0x3011, + ('=', 'T'): 0x3012, + ('=', '_'): 0x3013, + ('(', '\''): 0x3014, + (')', '\''): 0x3015, + ('(', 'I'): 0x3016, + (')', 'I'): 0x3017, + ('-', '?'): 0x301c, + ('A', '5'): 0x3041, + ('a', '5'): 0x3042, + ('I', '5'): 0x3043, + ('i', '5'): 0x3044, + ('U', '5'): 0x3045, + ('u', '5'): 0x3046, + ('E', '5'): 0x3047, + ('e', '5'): 0x3048, + ('O', '5'): 0x3049, + ('o', '5'): 0x304a, + ('k', 'a'): 0x304b, + ('g', 'a'): 0x304c, + ('k', 'i'): 0x304d, + ('g', 'i'): 0x304e, + ('k', 'u'): 0x304f, + ('g', 'u'): 0x3050, + ('k', 'e'): 0x3051, + ('g', 'e'): 0x3052, + ('k', 'o'): 0x3053, + ('g', 'o'): 0x3054, + ('s', 'a'): 0x3055, + ('z', 'a'): 0x3056, + ('s', 'i'): 0x3057, + ('z', 'i'): 0x3058, + ('s', 'u'): 0x3059, + ('z', 'u'): 0x305a, + ('s', 'e'): 0x305b, + ('z', 'e'): 0x305c, + ('s', 'o'): 0x305d, + ('z', 'o'): 0x305e, + ('t', 'a'): 0x305f, + ('d', 'a'): 0x3060, + ('t', 'i'): 0x3061, + ('d', 'i'): 0x3062, + ('t', 'U'): 0x3063, + ('t', 'u'): 0x3064, + ('d', 'u'): 0x3065, + ('t', 'e'): 0x3066, + ('d', 'e'): 0x3067, + ('t', 'o'): 0x3068, + ('d', 'o'): 0x3069, + ('n', 'a'): 0x306a, + ('n', 'i'): 0x306b, + ('n', 'u'): 0x306c, + ('n', 'e'): 0x306d, + ('n', 'o'): 0x306e, + ('h', 'a'): 0x306f, + ('b', 'a'): 0x3070, + ('p', 'a'): 0x3071, + ('h', 'i'): 0x3072, + ('b', 'i'): 0x3073, + ('p', 'i'): 0x3074, + ('h', 'u'): 0x3075, + ('b', 'u'): 0x3076, + ('p', 'u'): 0x3077, + ('h', 'e'): 0x3078, + ('b', 'e'): 0x3079, + ('p', 'e'): 0x307a, + ('h', 'o'): 0x307b, + ('b', 'o'): 0x307c, + ('p', 'o'): 0x307d, + ('m', 'a'): 0x307e, + ('m', 'i'): 0x307f, + ('m', 'u'): 0x3080, + ('m', 'e'): 0x3081, + ('m', 'o'): 0x3082, + ('y', 'A'): 0x3083, + ('y', 'a'): 0x3084, + ('y', 'U'): 0x3085, + ('y', 'u'): 0x3086, + ('y', 'O'): 0x3087, + ('y', 'o'): 0x3088, + ('r', 'a'): 0x3089, + ('r', 'i'): 0x308a, + ('r', 'u'): 0x308b, + ('r', 'e'): 0x308c, + ('r', 'o'): 0x308d, + ('w', 'A'): 0x308e, + ('w', 'a'): 0x308f, + ('w', 'i'): 0x3090, + ('w', 'e'): 0x3091, + ('w', 'o'): 0x3092, + ('n', '5'): 0x3093, + ('v', 'u'): 0x3094, + ('"', '5'): 0x309b, + ('0', '5'): 0x309c, + ('*', '5'): 0x309d, + ('+', '5'): 0x309e, + ('a', '6'): 0x30a1, + ('A', '6'): 0x30a2, + ('i', '6'): 0x30a3, + ('I', '6'): 0x30a4, + ('u', '6'): 0x30a5, + ('U', '6'): 0x30a6, + ('e', '6'): 0x30a7, + ('E', '6'): 0x30a8, + ('o', '6'): 0x30a9, + ('O', '6'): 0x30aa, + ('K', 'a'): 0x30ab, + ('G', 'a'): 0x30ac, + ('K', 'i'): 0x30ad, + ('G', 'i'): 0x30ae, + ('K', 'u'): 0x30af, + ('G', 'u'): 0x30b0, + ('K', 'e'): 0x30b1, + ('G', 'e'): 0x30b2, + ('K', 'o'): 0x30b3, + ('G', 'o'): 0x30b4, + ('S', 'a'): 0x30b5, + ('Z', 'a'): 0x30b6, + ('S', 'i'): 0x30b7, + ('Z', 'i'): 0x30b8, + ('S', 'u'): 0x30b9, + ('Z', 'u'): 0x30ba, + ('S', 'e'): 0x30bb, + ('Z', 'e'): 0x30bc, + ('S', 'o'): 0x30bd, + ('Z', 'o'): 0x30be, + ('T', 'a'): 0x30bf, + ('D', 'a'): 0x30c0, + ('T', 'i'): 0x30c1, + ('D', 'i'): 0x30c2, + ('T', 'U'): 0x30c3, + ('T', 'u'): 0x30c4, + ('D', 'u'): 0x30c5, + ('T', 'e'): 0x30c6, + ('D', 'e'): 0x30c7, + ('T', 'o'): 0x30c8, + ('D', 'o'): 0x30c9, + ('N', 'a'): 0x30ca, + ('N', 'i'): 0x30cb, + ('N', 'u'): 0x30cc, + ('N', 'e'): 0x30cd, + ('N', 'o'): 0x30ce, + ('H', 'a'): 0x30cf, + ('B', 'a'): 0x30d0, + ('P', 'a'): 0x30d1, + ('H', 'i'): 0x30d2, + ('B', 'i'): 0x30d3, + ('P', 'i'): 0x30d4, + ('H', 'u'): 0x30d5, + ('B', 'u'): 0x30d6, + ('P', 'u'): 0x30d7, + ('H', 'e'): 0x30d8, + ('B', 'e'): 0x30d9, + ('P', 'e'): 0x30da, + ('H', 'o'): 0x30db, + ('B', 'o'): 0x30dc, + ('P', 'o'): 0x30dd, + ('M', 'a'): 0x30de, + ('M', 'i'): 0x30df, + ('M', 'u'): 0x30e0, + ('M', 'e'): 0x30e1, + ('M', 'o'): 0x30e2, + ('Y', 'A'): 0x30e3, + ('Y', 'a'): 0x30e4, + ('Y', 'U'): 0x30e5, + ('Y', 'u'): 0x30e6, + ('Y', 'O'): 0x30e7, + ('Y', 'o'): 0x30e8, + ('R', 'a'): 0x30e9, + ('R', 'i'): 0x30ea, + ('R', 'u'): 0x30eb, + ('R', 'e'): 0x30ec, + ('R', 'o'): 0x30ed, + ('W', 'A'): 0x30ee, + ('W', 'a'): 0x30ef, + ('W', 'i'): 0x30f0, + ('W', 'e'): 0x30f1, + ('W', 'o'): 0x30f2, + ('N', '6'): 0x30f3, + ('V', 'u'): 0x30f4, + ('K', 'A'): 0x30f5, + ('K', 'E'): 0x30f6, + ('V', 'a'): 0x30f7, + ('V', 'i'): 0x30f8, + ('V', 'e'): 0x30f9, + ('V', 'o'): 0x30fa, + ('.', '6'): 0x30fb, + ('-', '6'): 0x30fc, + ('*', '6'): 0x30fd, + ('+', '6'): 0x30fe, + ('b', '4'): 0x3105, + ('p', '4'): 0x3106, + ('m', '4'): 0x3107, + ('f', '4'): 0x3108, + ('d', '4'): 0x3109, + ('t', '4'): 0x310a, + ('n', '4'): 0x310b, + ('l', '4'): 0x310c, + ('g', '4'): 0x310d, + ('k', '4'): 0x310e, + ('h', '4'): 0x310f, + ('j', '4'): 0x3110, + ('q', '4'): 0x3111, + ('x', '4'): 0x3112, + ('z', 'h'): 0x3113, + ('c', 'h'): 0x3114, + ('s', 'h'): 0x3115, + ('r', '4'): 0x3116, + ('z', '4'): 0x3117, + ('c', '4'): 0x3118, + ('s', '4'): 0x3119, + ('a', '4'): 0x311a, + ('o', '4'): 0x311b, + ('e', '4'): 0x311c, + ('a', 'i'): 0x311e, + ('e', 'i'): 0x311f, + ('a', 'u'): 0x3120, + ('o', 'u'): 0x3121, + ('a', 'n'): 0x3122, + ('e', 'n'): 0x3123, + ('a', 'N'): 0x3124, + ('e', 'N'): 0x3125, + ('e', 'r'): 0x3126, + ('i', '4'): 0x3127, + ('u', '4'): 0x3128, + ('i', 'u'): 0x3129, + ('v', '4'): 0x312a, + ('n', 'G'): 0x312b, + ('g', 'n'): 0x312c, + ('1', 'c'): 0x3220, + ('2', 'c'): 0x3221, + ('3', 'c'): 0x3222, + ('4', 'c'): 0x3223, + ('5', 'c'): 0x3224, + ('6', 'c'): 0x3225, + ('7', 'c'): 0x3226, + ('8', 'c'): 0x3227, + ('9', 'c'): 0x3228, + + # code points 0xe000 - 0xefff excluded, they have no assigned + # characters, only used in proposals. + ('f', 'f'): 0xfb00, + ('f', 'i'): 0xfb01, + ('f', 'l'): 0xfb02, + ('f', 't'): 0xfb05, + ('s', 't'): 0xfb06, + + # Vim 5.x compatible digraphs that don't conflict with the above + ('~', '!'): 161, + ('c', '|'): 162, + ('$', '$'): 163, + ('o', 'x'): 164, # currency symbol in ISO 8859-1 + ('Y', '-'): 165, + ('|', '|'): 166, + ('c', 'O'): 169, + ('-', ','): 172, + ('-', '='): 175, + ('~', 'o'): 176, + ('2', '2'): 178, + ('3', '3'): 179, + ('p', 'p'): 182, + ('~', '.'): 183, + ('1', '1'): 185, + ('~', '?'): 191, + ('A', '`'): 192, + ('A', '^'): 194, + ('A', '~'): 195, + ('A', '"'): 196, + ('A', '@'): 197, + ('E', '`'): 200, + ('E', '^'): 202, + ('E', '"'): 203, + ('I', '`'): 204, + ('I', '^'): 206, + ('I', '"'): 207, + ('N', '~'): 209, + ('O', '`'): 210, + ('O', '^'): 212, + ('O', '~'): 213, + ('/', '\\'): 215, # multiplication symbol in ISO 8859-1 + ('U', '`'): 217, + ('U', '^'): 219, + ('I', 'p'): 222, + ('a', '`'): 224, + ('a', '^'): 226, + ('a', '~'): 227, + ('a', '"'): 228, + ('a', '@'): 229, + ('e', '`'): 232, + ('e', '^'): 234, + ('e', '"'): 235, + ('i', '`'): 236, + ('i', '^'): 238, + ('n', '~'): 241, + ('o', '`'): 242, + ('o', '^'): 244, + ('o', '~'): 245, + ('u', '`'): 249, + ('u', '^'): 251, + ('y', '"'): 255, +} diff --git a/src/libs/prompt_toolkit/key_binding/input_processor.py b/src/libs/prompt_toolkit/key_binding/input_processor.py new file mode 100644 index 0000000..383e9f5 --- /dev/null +++ b/src/libs/prompt_toolkit/key_binding/input_processor.py @@ -0,0 +1,372 @@ +# *** encoding: utf-8 *** +""" +An :class:`~.InputProcessor` receives callbacks for the keystrokes parsed from +the input in the :class:`~libs.prompt_toolkit.inputstream.InputStream` instance. + +The `InputProcessor` will according to the implemented keybindings call the +correct callbacks when new key presses are feed through `feed`. +""" +from __future__ import unicode_literals +from libs.prompt_toolkit.buffer import EditReadOnlyBuffer +from libs.prompt_toolkit.filters.cli import ViNavigationMode +from libs.prompt_toolkit.keys import Keys, Key +from libs.prompt_toolkit.utils import Event + +from .registry import BaseRegistry + +from collections import deque +from six.moves import range +import weakref +import six + +__all__ = ( + 'InputProcessor', + 'KeyPress', +) + + +class KeyPress(object): + """ + :param key: A `Keys` instance or text (one character). + :param data: The received string on stdin. (Often vt100 escape codes.) + """ + def __init__(self, key, data=None): + assert isinstance(key, (six.text_type, Key)) + assert data is None or isinstance(data, six.text_type) + + if data is None: + data = key.name if isinstance(key, Key) else key + + self.key = key + self.data = data + + def __repr__(self): + return '%s(key=%r, data=%r)' % ( + self.__class__.__name__, self.key, self.data) + + def __eq__(self, other): + return self.key == other.key and self.data == other.data + + +class InputProcessor(object): + """ + Statemachine that receives :class:`KeyPress` instances and according to the + key bindings in the given :class:`Registry`, calls the matching handlers. + + :: + + p = InputProcessor(registry) + + # Send keys into the processor. + p.feed(KeyPress(Keys.ControlX, '\x18')) + p.feed(KeyPress(Keys.ControlC, '\x03') + + # Process all the keys in the queue. + p.process_keys() + + # Now the ControlX-ControlC callback will be called if this sequence is + # registered in the registry. + + :param registry: `BaseRegistry` instance. + :param cli_ref: weakref to `CommandLineInterface`. + """ + def __init__(self, registry, cli_ref): + assert isinstance(registry, BaseRegistry) + + self._registry = registry + self._cli_ref = cli_ref + + self.beforeKeyPress = Event(self) + self.afterKeyPress = Event(self) + + # The queue of keys not yet send to our _process generator/state machine. + self.input_queue = deque() + + # The key buffer that is matched in the generator state machine. + # (This is at at most the amount of keys that make up for one key binding.) + self.key_buffer = [] + + # Simple macro recording. (Like readline does.) + self.record_macro = False + self.macro = [] + + self.reset() + + def reset(self): + self._previous_key_sequence = [] + self._previous_handler = None + + self._process_coroutine = self._process() + self._process_coroutine.send(None) + + #: Readline argument (for repetition of commands.) + #: https://www.gnu.org/software/bash/manual/html_node/Readline-Arguments.html + self.arg = None + + def start_macro(self): + " Start recording macro. " + self.record_macro = True + self.macro = [] + + def end_macro(self): + " End recording macro. " + self.record_macro = False + + def call_macro(self): + for k in self.macro: + self.feed(k) + + def _get_matches(self, key_presses): + """ + For a list of :class:`KeyPress` instances. Give the matching handlers + that would handle this. + """ + keys = tuple(k.key for k in key_presses) + cli = self._cli_ref() + + # Try match, with mode flag + return [b for b in self._registry.get_bindings_for_keys(keys) if b.filter(cli)] + + def _is_prefix_of_longer_match(self, key_presses): + """ + For a list of :class:`KeyPress` instances. Return True if there is any + handler that is bound to a suffix of this keys. + """ + keys = tuple(k.key for k in key_presses) + cli = self._cli_ref() + + # Get the filters for all the key bindings that have a longer match. + # Note that we transform it into a `set`, because we don't care about + # the actual bindings and executing it more than once doesn't make + # sense. (Many key bindings share the same filter.) + filters = set(b.filter for b in self._registry.get_bindings_starting_with_keys(keys)) + + # When any key binding is active, return True. + return any(f(cli) for f in filters) + + def _process(self): + """ + Coroutine implementing the key match algorithm. Key strokes are sent + into this generator, and it calls the appropriate handlers. + """ + buffer = self.key_buffer + retry = False + + while True: + if retry: + retry = False + else: + buffer.append((yield)) + + # If we have some key presses, check for matches. + if buffer: + is_prefix_of_longer_match = self._is_prefix_of_longer_match(buffer) + matches = self._get_matches(buffer) + + # When eager matches were found, give priority to them and also + # ignore all the longer matches. + eager_matches = [m for m in matches if m.eager(self._cli_ref())] + + if eager_matches: + matches = eager_matches + is_prefix_of_longer_match = False + + # Exact matches found, call handler. + if not is_prefix_of_longer_match and matches: + self._call_handler(matches[-1], key_sequence=buffer[:]) + del buffer[:] # Keep reference. + + # No match found. + elif not is_prefix_of_longer_match and not matches: + retry = True + found = False + + # Loop over the input, try longest match first and shift. + for i in range(len(buffer), 0, -1): + matches = self._get_matches(buffer[:i]) + if matches: + self._call_handler(matches[-1], key_sequence=buffer[:i]) + del buffer[:i] + found = True + break + + if not found: + del buffer[:1] + + def feed(self, key_press): + """ + Add a new :class:`KeyPress` to the input queue. + (Don't forget to call `process_keys` in order to process the queue.) + """ + assert isinstance(key_press, KeyPress) + self.input_queue.append(key_press) + + def process_keys(self): + """ + Process all the keys in the `input_queue`. + (To be called after `feed`.) + + Note: because of the `feed`/`process_keys` separation, it is + possible to call `feed` from inside a key binding. + This function keeps looping until the queue is empty. + """ + while self.input_queue: + key_press = self.input_queue.popleft() + + if key_press.key != Keys.CPRResponse: + self.beforeKeyPress.fire() + + self._process_coroutine.send(key_press) + + if key_press.key != Keys.CPRResponse: + self.afterKeyPress.fire() + + # Invalidate user interface. + cli = self._cli_ref() + if cli: + cli.invalidate() + + def _call_handler(self, handler, key_sequence=None): + was_recording = self.record_macro + arg = self.arg + self.arg = None + + event = KeyPressEvent( + weakref.ref(self), arg=arg, key_sequence=key_sequence, + previous_key_sequence=self._previous_key_sequence, + is_repeat=(handler == self._previous_handler)) + + # Save the state of the current buffer. + cli = event.cli # Can be `None` (In unit-tests only.) + + if handler.save_before(event) and cli: + cli.current_buffer.save_to_undo_stack() + + # Call handler. + try: + handler.call(event) + self._fix_vi_cursor_position(event) + + except EditReadOnlyBuffer: + # When a key binding does an attempt to change a buffer which is + # read-only, we can just silently ignore that. + pass + + self._previous_key_sequence = key_sequence + self._previous_handler = handler + + # Record the key sequence in our macro. (Only if we're in macro mode + # before and after executing the key.) + if self.record_macro and was_recording: + self.macro.extend(key_sequence) + + def _fix_vi_cursor_position(self, event): + """ + After every command, make sure that if we are in Vi navigation mode, we + never put the cursor after the last character of a line. (Unless it's + an empty line.) + """ + cli = self._cli_ref() + if cli: + buff = cli.current_buffer + preferred_column = buff.preferred_column + + if (ViNavigationMode()(event.cli) and + buff.document.is_cursor_at_the_end_of_line and + len(buff.document.current_line) > 0): + buff.cursor_position -= 1 + + # Set the preferred_column for arrow up/down again. + # (This was cleared after changing the cursor position.) + buff.preferred_column = preferred_column + + + +class KeyPressEvent(object): + """ + Key press event, delivered to key bindings. + + :param input_processor_ref: Weak reference to the `InputProcessor`. + :param arg: Repetition argument. + :param key_sequence: List of `KeyPress` instances. + :param previouskey_sequence: Previous list of `KeyPress` instances. + :param is_repeat: True when the previous event was delivered to the same handler. + """ + def __init__(self, input_processor_ref, arg=None, key_sequence=None, + previous_key_sequence=None, is_repeat=False): + self._input_processor_ref = input_processor_ref + self.key_sequence = key_sequence + self.previous_key_sequence = previous_key_sequence + + #: True when the previous key sequence was handled by the same handler. + self.is_repeat = is_repeat + + self._arg = arg + + def __repr__(self): + return 'KeyPressEvent(arg=%r, key_sequence=%r, is_repeat=%r)' % ( + self.arg, self.key_sequence, self.is_repeat) + + @property + def data(self): + return self.key_sequence[-1].data + + @property + def input_processor(self): + return self._input_processor_ref() + + @property + def cli(self): + """ + Command line interface. + """ + return self.input_processor._cli_ref() + + @property + def current_buffer(self): + """ + The current buffer. + """ + return self.cli.current_buffer + + @property + def arg(self): + """ + Repetition argument. + """ + if self._arg == '-': + return -1 + + result = int(self._arg or 1) + + # Don't exceed a million. + if int(result) >= 1000000: + result = 1 + + return result + + @property + def arg_present(self): + """ + True if repetition argument was explicitly provided. + """ + return self._arg is not None + + def append_to_arg_count(self, data): + """ + Add digit to the input argument. + + :param data: the typed digit as string + """ + assert data in '-0123456789' + current = self._arg + + if data == '-': + assert current is None or current == '-' + result = data + elif current is None: + result = data + else: + result = "%s%s" % (current, data) + + self.input_processor.arg = result diff --git a/src/libs/prompt_toolkit/key_binding/manager.py b/src/libs/prompt_toolkit/key_binding/manager.py new file mode 100644 index 0000000..8ed7a4e --- /dev/null +++ b/src/libs/prompt_toolkit/key_binding/manager.py @@ -0,0 +1,96 @@ +""" +DEPRECATED: +Use `libs.prompt_toolkit.key_binding.defaults.load_key_bindings` instead. + +:class:`KeyBindingManager` is a utility (or shortcut) for loading all the key +bindings in a key binding registry, with a logic set of filters to quickly to +quickly change from Vi to Emacs key bindings at runtime. + +You don't have to use this, but it's practical. + +Usage:: + + manager = KeyBindingManager() + app = Application(key_bindings_registry=manager.registry) +""" +from __future__ import unicode_literals +from .defaults import load_key_bindings +from libs.prompt_toolkit.filters import to_cli_filter +from libs.prompt_toolkit.key_binding.registry import Registry, ConditionalRegistry, MergedRegistry + +__all__ = ( + 'KeyBindingManager', +) + + +class KeyBindingManager(object): + """ + Utility for loading all key bindings into memory. + + :param registry: Optional `Registry` instance. + :param enable_abort_and_exit_bindings: Filter to enable Ctrl-C and Ctrl-D. + :param enable_system_bindings: Filter to enable the system bindings + (meta-! prompt and Control-Z suspension.) + :param enable_search: Filter to enable the search bindings. + :param enable_open_in_editor: Filter to enable open-in-editor. + :param enable_open_in_editor: Filter to enable open-in-editor. + :param enable_extra_page_navigation: Filter for enabling extra page navigation. + (Bindings for up/down scrolling through long pages, like in Emacs or Vi.) + :param enable_auto_suggest_bindings: Filter to enable fish-style suggestions. + + :param enable_vi_mode: Deprecated! + """ + def __init__(self, + registry=None, # XXX: not used anymore. + enable_vi_mode=None, # (`enable_vi_mode` is deprecated.) + enable_all=True, # + get_search_state=None, + enable_abort_and_exit_bindings=False, + enable_system_bindings=False, + enable_search=False, + enable_open_in_editor=False, + enable_extra_page_navigation=False, + enable_auto_suggest_bindings=False): + + assert registry is None or isinstance(registry, Registry) + assert get_search_state is None or callable(get_search_state) + enable_all = to_cli_filter(enable_all) + + defaults = load_key_bindings( + get_search_state=get_search_state, + enable_abort_and_exit_bindings=enable_abort_and_exit_bindings, + enable_system_bindings=enable_system_bindings, + enable_search=enable_search, + enable_open_in_editor=enable_open_in_editor, + enable_extra_page_navigation=enable_extra_page_navigation, + enable_auto_suggest_bindings=enable_auto_suggest_bindings) + + # Note, we wrap this whole thing again in a MergedRegistry, because we + # don't want the `enable_all` settings to apply on items that were + # added to the registry as a whole. + self.registry = MergedRegistry([ + ConditionalRegistry(defaults, enable_all) + ]) + + @classmethod + def for_prompt(cls, **kw): + """ + Create a ``KeyBindingManager`` with the defaults for an input prompt. + This activates the key bindings for abort/exit (Ctrl-C/Ctrl-D), + incremental search and auto suggestions. + + (Not for full screen applications.) + """ + kw.setdefault('enable_abort_and_exit_bindings', True) + kw.setdefault('enable_search', True) + kw.setdefault('enable_auto_suggest_bindings', True) + + return cls(**kw) + + def reset(self, cli): + # For backwards compatibility. + pass + + def get_vi_state(self, cli): + # Deprecated! + return cli.vi_state diff --git a/src/libs/prompt_toolkit/key_binding/registry.py b/src/libs/prompt_toolkit/key_binding/registry.py new file mode 100644 index 0000000..f3cf1ee --- /dev/null +++ b/src/libs/prompt_toolkit/key_binding/registry.py @@ -0,0 +1,350 @@ +""" +Key bindings registry. + +A `Registry` object is a container that holds a list of key bindings. It has a +very efficient internal data structure for checking which key bindings apply +for a pressed key. + +Typical usage:: + + r = Registry() + + @r.add_binding(Keys.ControlX, Keys.ControlC, filter=INSERT) + def handler(event): + # Handle ControlX-ControlC key sequence. + pass + + +It is also possible to combine multiple registries. We do this in the default +key bindings. There are some registries that contain Emacs bindings, while +others contain the Vi bindings. They are merged together using a +`MergedRegistry`. + +We also have a `ConditionalRegistry` object that can enable/disable a group of +key bindings at once. +""" +from __future__ import unicode_literals +from abc import ABCMeta, abstractmethod + +from libs.prompt_toolkit.cache import SimpleCache +from libs.prompt_toolkit.filters import CLIFilter, to_cli_filter, Never +from libs.prompt_toolkit.keys import Key, Keys + +from six import text_type, with_metaclass + +__all__ = ( + 'BaseRegistry', + 'Registry', + 'ConditionalRegistry', + 'MergedRegistry', +) + + +class _Binding(object): + """ + (Immutable binding class.) + """ + def __init__(self, keys, handler, filter=None, eager=None, save_before=None): + assert isinstance(keys, tuple) + assert callable(handler) + assert isinstance(filter, CLIFilter) + assert isinstance(eager, CLIFilter) + assert callable(save_before) + + self.keys = keys + self.handler = handler + self.filter = filter + self.eager = eager + self.save_before = save_before + + def call(self, event): + return self.handler(event) + + def __repr__(self): + return '%s(keys=%r, handler=%r)' % ( + self.__class__.__name__, self.keys, self.handler) + + +class BaseRegistry(with_metaclass(ABCMeta, object)): + """ + Interface for a Registry. + """ + _version = 0 # For cache invalidation. + + @abstractmethod + def get_bindings_for_keys(self, keys): + pass + + @abstractmethod + def get_bindings_starting_with_keys(self, keys): + pass + + # `add_binding` and `remove_binding` don't have to be part of this + # interface. + + +class Registry(BaseRegistry): + """ + Key binding registry. + """ + def __init__(self): + self.key_bindings = [] + self._get_bindings_for_keys_cache = SimpleCache(maxsize=10000) + self._get_bindings_starting_with_keys_cache = SimpleCache(maxsize=1000) + self._version = 0 # For cache invalidation. + + def _clear_cache(self): + self._version += 1 + self._get_bindings_for_keys_cache.clear() + self._get_bindings_starting_with_keys_cache.clear() + + def add_binding(self, *keys, **kwargs): + """ + Decorator for annotating key bindings. + + :param filter: :class:`~libs.prompt_toolkit.filters.CLIFilter` to determine + when this key binding is active. + :param eager: :class:`~libs.prompt_toolkit.filters.CLIFilter` or `bool`. + When True, ignore potential longer matches when this key binding is + hit. E.g. when there is an active eager key binding for Ctrl-X, + execute the handler immediately and ignore the key binding for + Ctrl-X Ctrl-E of which it is a prefix. + :param save_before: Callable that takes an `Event` and returns True if + we should save the current buffer, before handling the event. + (That's the default.) + """ + filter = to_cli_filter(kwargs.pop('filter', True)) + eager = to_cli_filter(kwargs.pop('eager', False)) + save_before = kwargs.pop('save_before', lambda e: True) + to_cli_filter(kwargs.pop('invalidate_ui', True)) # Deprecated! (ignored.) + + assert not kwargs + assert keys + assert all(isinstance(k, (Key, text_type)) for k in keys), \ + 'Key bindings should consist of Key and string (unicode) instances.' + assert callable(save_before) + + if isinstance(filter, Never): + # When a filter is Never, it will always stay disabled, so in that case + # don't bother putting it in the registry. It will slow down every key + # press otherwise. + def decorator(func): + return func + else: + def decorator(func): + self.key_bindings.append( + _Binding(keys, func, filter=filter, eager=eager, + save_before=save_before)) + self._clear_cache() + + return func + return decorator + + def remove_binding(self, function): + """ + Remove a key binding. + + This expects a function that was given to `add_binding` method as + parameter. Raises `ValueError` when the given function was not + registered before. + """ + assert callable(function) + + for b in self.key_bindings: + if b.handler == function: + self.key_bindings.remove(b) + self._clear_cache() + return + + # No key binding found for this function. Raise ValueError. + raise ValueError('Binding not found: %r' % (function, )) + + def get_bindings_for_keys(self, keys): + """ + Return a list of key bindings that can handle this key. + (This return also inactive bindings, so the `filter` still has to be + called, for checking it.) + + :param keys: tuple of keys. + """ + def get(): + result = [] + for b in self.key_bindings: + if len(keys) == len(b.keys): + match = True + any_count = 0 + + for i, j in zip(b.keys, keys): + if i != j and i != Keys.Any: + match = False + break + + if i == Keys.Any: + any_count += 1 + + if match: + result.append((any_count, b)) + + # Place bindings that have more 'Any' occurences in them at the end. + result = sorted(result, key=lambda item: -item[0]) + + return [item[1] for item in result] + + return self._get_bindings_for_keys_cache.get(keys, get) + + def get_bindings_starting_with_keys(self, keys): + """ + Return a list of key bindings that handle a key sequence starting with + `keys`. (It does only return bindings for which the sequences are + longer than `keys`. And like `get_bindings_for_keys`, it also includes + inactive bindings.) + + :param keys: tuple of keys. + """ + def get(): + result = [] + for b in self.key_bindings: + if len(keys) < len(b.keys): + match = True + for i, j in zip(b.keys, keys): + if i != j and i != Keys.Any: + match = False + break + if match: + result.append(b) + return result + + return self._get_bindings_starting_with_keys_cache.get(keys, get) + + +class _AddRemoveMixin(BaseRegistry): + """ + Common part for ConditionalRegistry and MergedRegistry. + """ + def __init__(self): + # `Registry` to be synchronized with all the others. + self._registry2 = Registry() + self._last_version = None + + # The 'extra' registry. Mostly for backwards compatibility. + self._extra_registry = Registry() + + def _update_cache(self): + raise NotImplementedError + + # For backwards, compatibility, we allow adding bindings to both + # ConditionalRegistry and MergedRegistry. This is however not the + # recommended way. Better is to create a new registry and merge them + # together using MergedRegistry. + + def add_binding(self, *k, **kw): + return self._extra_registry.add_binding(*k, **kw) + + def remove_binding(self, *k, **kw): + return self._extra_registry.remove_binding(*k, **kw) + + # Proxy methods to self._registry2. + + @property + def key_bindings(self): + self._update_cache() + return self._registry2.key_bindings + + @property + def _version(self): + self._update_cache() + return self._last_version + + def get_bindings_for_keys(self, *a, **kw): + self._update_cache() + return self._registry2.get_bindings_for_keys(*a, **kw) + + def get_bindings_starting_with_keys(self, *a, **kw): + self._update_cache() + return self._registry2.get_bindings_starting_with_keys(*a, **kw) + + +class ConditionalRegistry(_AddRemoveMixin): + """ + Wraps around a `Registry`. Disable/enable all the key bindings according to + the given (additional) filter.:: + + @Condition + def setting_is_true(cli): + return True # or False + + registy = ConditionalRegistry(registry, setting_is_true) + + When new key bindings are added to this object. They are also + enable/disabled according to the given `filter`. + + :param registries: List of `Registry` objects. + :param filter: `CLIFilter` object. + """ + def __init__(self, registry=None, filter=True): + registry = registry or Registry() + assert isinstance(registry, BaseRegistry) + + _AddRemoveMixin.__init__(self) + + self.registry = registry + self.filter = to_cli_filter(filter) + + def _update_cache(self): + " If the original registry was changed. Update our copy version. " + expected_version = (self.registry._version, self._extra_registry._version) + + if self._last_version != expected_version: + registry2 = Registry() + + # Copy all bindings from `self.registry`, adding our condition. + for reg in (self.registry, self._extra_registry): + for b in reg.key_bindings: + registry2.key_bindings.append( + _Binding( + keys=b.keys, + handler=b.handler, + filter=self.filter & b.filter, + eager=b.eager, + save_before=b.save_before)) + + self._registry2 = registry2 + self._last_version = expected_version + + +class MergedRegistry(_AddRemoveMixin): + """ + Merge multiple registries of key bindings into one. + + This class acts as a proxy to multiple `Registry` objects, but behaves as + if this is just one bigger `Registry`. + + :param registries: List of `Registry` objects. + """ + def __init__(self, registries): + assert all(isinstance(r, BaseRegistry) for r in registries) + + _AddRemoveMixin.__init__(self) + + self.registries = registries + + def _update_cache(self): + """ + If one of the original registries was changed. Update our merged + version. + """ + expected_version = ( + tuple(r._version for r in self.registries) + + (self._extra_registry._version, )) + + if self._last_version != expected_version: + registry2 = Registry() + + for reg in self.registries: + registry2.key_bindings.extend(reg.key_bindings) + + # Copy all bindings from `self._extra_registry`. + registry2.key_bindings.extend(self._extra_registry.key_bindings) + + self._registry2 = registry2 + self._last_version = expected_version diff --git a/src/libs/prompt_toolkit/key_binding/vi_state.py b/src/libs/prompt_toolkit/key_binding/vi_state.py new file mode 100644 index 0000000..92ce3cb --- /dev/null +++ b/src/libs/prompt_toolkit/key_binding/vi_state.py @@ -0,0 +1,61 @@ +from __future__ import unicode_literals + +__all__ = ( + 'InputMode', + 'CharacterFind', + 'ViState', +) + + +class InputMode(object): + INSERT = 'vi-insert' + INSERT_MULTIPLE = 'vi-insert-multiple' + NAVIGATION = 'vi-navigation' + REPLACE = 'vi-replace' + + +class CharacterFind(object): + def __init__(self, character, backwards=False): + self.character = character + self.backwards = backwards + + +class ViState(object): + """ + Mutable class to hold the state of the Vi navigation. + """ + def __init__(self): + #: None or CharacterFind instance. (This is used to repeat the last + #: search in Vi mode, by pressing the 'n' or 'N' in navigation mode.) + self.last_character_find = None + + # When an operator is given and we are waiting for text object, + # -- e.g. in the case of 'dw', after the 'd' --, an operator callback + # is set here. + self.operator_func = None + self.operator_arg = None + + #: Named registers. Maps register name (e.g. 'a') to + #: :class:`ClipboardData` instances. + self.named_registers = {} + + #: The Vi mode we're currently in to. + self.input_mode = InputMode.INSERT + + #: Waiting for digraph. + self.waiting_for_digraph = False + self.digraph_symbol1 = None # (None or a symbol.) + + #: When true, make ~ act as an operator. + self.tilde_operator = False + + def reset(self, mode=InputMode.INSERT): + """ + Reset state, go back to the given mode. INSERT by default. + """ + # Go back to insert mode. + self.input_mode = mode + + self.waiting_for_digraph = False + self.operator_func = None + self.operator_arg = None diff --git a/src/libs/prompt_toolkit/keys.py b/src/libs/prompt_toolkit/keys.py new file mode 100644 index 0000000..9ca4416 --- /dev/null +++ b/src/libs/prompt_toolkit/keys.py @@ -0,0 +1,129 @@ +from __future__ import unicode_literals + +__all__ = ( + 'Key', + 'Keys', +) + + +class Key(object): + def __init__(self, name): + + #: Descriptive way of writing keys in configuration files. e.g. + #: for ``Control-A``. + self.name = name + + def __repr__(self): + return '%s(%r)' % (self.__class__.__name__, self.name) + + +class Keys(object): + Escape = Key('') + + ControlA = Key('') + ControlB = Key('') + ControlC = Key('') + ControlD = Key('') + ControlE = Key('') + ControlF = Key('') + ControlG = Key('') + ControlH = Key('') + ControlI = Key('') # Tab + ControlJ = Key('') # Enter + ControlK = Key('') + ControlL = Key('') + ControlM = Key('') # Enter + ControlN = Key('') + ControlO = Key('') + ControlP = Key('') + ControlQ = Key('') + ControlR = Key('') + ControlS = Key('') + ControlT = Key('') + ControlU = Key('') + ControlV = Key('') + ControlW = Key('') + ControlX = Key('') + ControlY = Key('') + ControlZ = Key('') + + ControlSpace = Key('') + ControlBackslash = Key('') + ControlSquareClose = Key('') + ControlCircumflex = Key('') + ControlUnderscore = Key('') + ControlLeft = Key('') + ControlRight = Key('') + ControlUp = Key('') + ControlDown = Key('') + + Up = Key('') + Down = Key('') + Right = Key('') + Left = Key('') + + ShiftLeft = Key('') + ShiftUp = Key('') + ShiftDown = Key('') + ShiftRight = Key('') + + Home = Key('') + End = Key('') + Delete = Key('') + ShiftDelete = Key('') + ControlDelete = Key('') + PageUp = Key('') + PageDown = Key('') + BackTab = Key('') # shift + tab + Insert = Key('') + Backspace = Key('') + + # Aliases. + Tab = ControlI + Enter = ControlJ + # XXX: Actually Enter equals ControlM, not ControlJ, + # However, in libs.prompt_toolkit, we made the mistake of translating + # \r into \n during the input, so everyone is now handling the + # enter key by binding ControlJ. + + # From now on, it's better to bind `Keys.Enter` everywhere, + # because that's future compatible, and will still work when we + # stop replacing \r by \n. + + F1 = Key('') + F2 = Key('') + F3 = Key('') + F4 = Key('') + F5 = Key('') + F6 = Key('') + F7 = Key('') + F8 = Key('') + F9 = Key('') + F10 = Key('') + F11 = Key('') + F12 = Key('') + F13 = Key('') + F14 = Key('') + F15 = Key('') + F16 = Key('') + F17 = Key('') + F18 = Key('') + F19 = Key('') + F20 = Key('') + F21 = Key('') + F22 = Key('') + F23 = Key('') + F24 = Key('') + + # Matches any key. + Any = Key('') + + # Special + CPRResponse = Key('') + Vt100MouseEvent = Key('') + WindowsMouseEvent = Key('') + BracketedPaste = Key('') + + # Key which is ignored. (The key binding for this key should not do + # anything.) + Ignore = Key('') diff --git a/src/libs/prompt_toolkit/layout/__init__.py b/src/libs/prompt_toolkit/layout/__init__.py new file mode 100644 index 0000000..0dec5ec --- /dev/null +++ b/src/libs/prompt_toolkit/layout/__init__.py @@ -0,0 +1,51 @@ +""" +Command line layout definitions +------------------------------- + +The layout of a command line interface is defined by a Container instance. +There are two main groups of classes here. Containers and controls: + +- A container can contain other containers or controls, it can have multiple + children and it decides about the dimensions. +- A control is responsible for rendering the actual content to a screen. + A control can propose some dimensions, but it's the container who decides + about the dimensions -- or when the control consumes more space -- which part + of the control will be visible. + + +Container classes:: + + - Container (Abstract base class) + |- HSplit (Horizontal split) + |- VSplit (Vertical split) + |- FloatContainer (Container which can also contain menus and other floats) + `- Window (Container which contains one actual control + +Control classes:: + + - UIControl (Abstract base class) + |- TokenListControl (Renders a simple list of tokens) + |- FillControl (Fills control with one token/character.) + `- BufferControl (Renders an input buffer.) + + +Usually, you end up wrapping every control inside a `Window` object, because +that's the only way to render it in a layout. + +There are some prepared toolbars which are ready to use:: + +- SystemToolbar (Shows the 'system' input buffer, for entering system commands.) +- ArgToolbar (Shows the input 'arg', for repetition of input commands.) +- SearchToolbar (Shows the 'search' input buffer, for incremental search.) +- CompletionsToolbar (Shows the completions of the current buffer.) +- ValidationToolbar (Shows validation errors of the current buffer.) + +And one prepared menu: + +- CompletionsMenu + +""" +from __future__ import unicode_literals + +from .containers import Float, FloatContainer, HSplit, VSplit, Window, ConditionalContainer +from .controls import TokenListControl, FillControl, BufferControl diff --git a/src/libs/prompt_toolkit/layout/containers.py b/src/libs/prompt_toolkit/layout/containers.py new file mode 100644 index 0000000..11591bb --- /dev/null +++ b/src/libs/prompt_toolkit/layout/containers.py @@ -0,0 +1,1665 @@ +""" +Container for the layout. +(Containers can contain other containers or user interface controls.) +""" +from __future__ import unicode_literals + +from abc import ABCMeta, abstractmethod +from six import with_metaclass +from six.moves import range + +from .controls import UIControl, TokenListControl, UIContent +from .dimension import LayoutDimension, sum_layout_dimensions, max_layout_dimensions +from .margins import Margin +from .screen import Point, WritePosition, _CHAR_CACHE +from .utils import token_list_to_text, explode_tokens +from libs.prompt_toolkit.cache import SimpleCache +from libs.prompt_toolkit.filters import to_cli_filter, ViInsertMode, EmacsInsertMode +from libs.prompt_toolkit.mouse_events import MouseEvent, MouseEventType +from libs.prompt_toolkit.reactive import Integer +from libs.prompt_toolkit.token import Token +from libs.prompt_toolkit.utils import take_using_weights, get_cwidth + +__all__ = ( + 'Container', + 'HSplit', + 'VSplit', + 'FloatContainer', + 'Float', + 'Window', + 'WindowRenderInfo', + 'ConditionalContainer', + 'ScrollOffsets', + 'ColorColumn', +) + +Transparent = Token.Transparent + + +class Container(with_metaclass(ABCMeta, object)): + """ + Base class for user interface layout. + """ + @abstractmethod + def reset(self): + """ + Reset the state of this container and all the children. + (E.g. reset scroll offsets, etc...) + """ + + @abstractmethod + def preferred_width(self, cli, max_available_width): + """ + Return a :class:`~libs.prompt_toolkit.layout.dimension.LayoutDimension` that + represents the desired width for this container. + + :param cli: :class:`~libs.prompt_toolkit.interface.CommandLineInterface`. + """ + + @abstractmethod + def preferred_height(self, cli, width, max_available_height): + """ + Return a :class:`~libs.prompt_toolkit.layout.dimension.LayoutDimension` that + represents the desired height for this container. + + :param cli: :class:`~libs.prompt_toolkit.interface.CommandLineInterface`. + """ + + @abstractmethod + def write_to_screen(self, cli, screen, mouse_handlers, write_position): + """ + Write the actual content to the screen. + + :param cli: :class:`~libs.prompt_toolkit.interface.CommandLineInterface`. + :param screen: :class:`~libs.prompt_toolkit.layout.screen.Screen` + :param mouse_handlers: :class:`~libs.prompt_toolkit.layout.mouse_handlers.MouseHandlers`. + """ + + @abstractmethod + def walk(self, cli): + """ + Walk through all the layout nodes (and their children) and yield them. + """ + + +def _window_too_small(): + " Create a `Window` that displays the 'Window too small' text. " + return Window(TokenListControl.static( + [(Token.WindowTooSmall, ' Window too small... ')])) + + +class HSplit(Container): + """ + Several layouts, one stacked above/under the other. + + :param children: List of child :class:`.Container` objects. + :param window_too_small: A :class:`.Container` object that is displayed if + there is not enough space for all the children. By default, this is a + "Window too small" message. + :param get_dimensions: (`None` or a callable that takes a + `CommandLineInterface` and returns a list of `LayoutDimension` + instances.) By default the dimensions are taken from the children and + divided by the available space. However, when `get_dimensions` is specified, + this is taken instead. + :param report_dimensions_callback: When rendering, this function is called + with the `CommandLineInterface` and the list of used dimensions. (As a + list of integers.) + """ + def __init__(self, children, window_too_small=None, + get_dimensions=None, report_dimensions_callback=None): + assert all(isinstance(c, Container) for c in children) + assert window_too_small is None or isinstance(window_too_small, Container) + assert get_dimensions is None or callable(get_dimensions) + assert report_dimensions_callback is None or callable(report_dimensions_callback) + + self.children = children + self.window_too_small = window_too_small or _window_too_small() + self.get_dimensions = get_dimensions + self.report_dimensions_callback = report_dimensions_callback + + def preferred_width(self, cli, max_available_width): + if self.children: + dimensions = [c.preferred_width(cli, max_available_width) for c in self.children] + return max_layout_dimensions(dimensions) + else: + return LayoutDimension(0) + + def preferred_height(self, cli, width, max_available_height): + dimensions = [c.preferred_height(cli, width, max_available_height) for c in self.children] + return sum_layout_dimensions(dimensions) + + def reset(self): + for c in self.children: + c.reset() + + def write_to_screen(self, cli, screen, mouse_handlers, write_position): + """ + Render the prompt to a `Screen` instance. + + :param screen: The :class:`~libs.prompt_toolkit.layout.screen.Screen` class + to which the output has to be written. + """ + sizes = self._divide_heigths(cli, write_position) + + if self.report_dimensions_callback: + self.report_dimensions_callback(cli, sizes) + + if sizes is None: + self.window_too_small.write_to_screen( + cli, screen, mouse_handlers, write_position) + else: + # Draw child panes. + ypos = write_position.ypos + xpos = write_position.xpos + width = write_position.width + + for s, c in zip(sizes, self.children): + c.write_to_screen(cli, screen, mouse_handlers, WritePosition(xpos, ypos, width, s)) + ypos += s + + def _divide_heigths(self, cli, write_position): + """ + Return the heights for all rows. + Or None when there is not enough space. + """ + if not self.children: + return [] + + # Calculate heights. + given_dimensions = self.get_dimensions(cli) if self.get_dimensions else None + + def get_dimension_for_child(c, index): + if given_dimensions and given_dimensions[index] is not None: + return given_dimensions[index] + else: + return c.preferred_height(cli, write_position.width, write_position.extended_height) + + dimensions = [get_dimension_for_child(c, index) for index, c in enumerate(self.children)] + + # Sum dimensions + sum_dimensions = sum_layout_dimensions(dimensions) + + # If there is not enough space for both. + # Don't do anything. + if sum_dimensions.min > write_position.extended_height: + return + + # Find optimal sizes. (Start with minimal size, increase until we cover + # the whole height.) + sizes = [d.min for d in dimensions] + + child_generator = take_using_weights( + items=list(range(len(dimensions))), + weights=[d.weight for d in dimensions]) + + i = next(child_generator) + + while sum(sizes) < min(write_position.extended_height, sum_dimensions.preferred): + # Increase until we meet at least the 'preferred' size. + if sizes[i] < dimensions[i].preferred: + sizes[i] += 1 + i = next(child_generator) + + if not any([cli.is_returning, cli.is_exiting, cli.is_aborting]): + while sum(sizes) < min(write_position.height, sum_dimensions.max): + # Increase until we use all the available space. (or until "max") + if sizes[i] < dimensions[i].max: + sizes[i] += 1 + i = next(child_generator) + + return sizes + + def walk(self, cli): + """ Walk through children. """ + yield self + for c in self.children: + for i in c.walk(cli): + yield i + + +class VSplit(Container): + """ + Several layouts, one stacked left/right of the other. + + :param children: List of child :class:`.Container` objects. + :param window_too_small: A :class:`.Container` object that is displayed if + there is not enough space for all the children. By default, this is a + "Window too small" message. + :param get_dimensions: (`None` or a callable that takes a + `CommandLineInterface` and returns a list of `LayoutDimension` + instances.) By default the dimensions are taken from the children and + divided by the available space. However, when `get_dimensions` is specified, + this is taken instead. + :param report_dimensions_callback: When rendering, this function is called + with the `CommandLineInterface` and the list of used dimensions. (As a + list of integers.) + """ + def __init__(self, children, window_too_small=None, + get_dimensions=None, report_dimensions_callback=None): + assert all(isinstance(c, Container) for c in children) + assert window_too_small is None or isinstance(window_too_small, Container) + assert get_dimensions is None or callable(get_dimensions) + assert report_dimensions_callback is None or callable(report_dimensions_callback) + + self.children = children + self.window_too_small = window_too_small or _window_too_small() + self.get_dimensions = get_dimensions + self.report_dimensions_callback = report_dimensions_callback + + def preferred_width(self, cli, max_available_width): + dimensions = [c.preferred_width(cli, max_available_width) for c in self.children] + return sum_layout_dimensions(dimensions) + + def preferred_height(self, cli, width, max_available_height): + sizes = self._divide_widths(cli, width) + if sizes is None: + return LayoutDimension() + else: + dimensions = [c.preferred_height(cli, s, max_available_height) + for s, c in zip(sizes, self.children)] + return max_layout_dimensions(dimensions) + + def reset(self): + for c in self.children: + c.reset() + + def _divide_widths(self, cli, width): + """ + Return the widths for all columns. + Or None when there is not enough space. + """ + if not self.children: + return [] + + # Calculate widths. + given_dimensions = self.get_dimensions(cli) if self.get_dimensions else None + + def get_dimension_for_child(c, index): + if given_dimensions and given_dimensions[index] is not None: + return given_dimensions[index] + else: + return c.preferred_width(cli, width) + + dimensions = [get_dimension_for_child(c, index) for index, c in enumerate(self.children)] + + # Sum dimensions + sum_dimensions = sum_layout_dimensions(dimensions) + + # If there is not enough space for both. + # Don't do anything. + if sum_dimensions.min > width: + return + + # Find optimal sizes. (Start with minimal size, increase until we cover + # the whole height.) + sizes = [d.min for d in dimensions] + + child_generator = take_using_weights( + items=list(range(len(dimensions))), + weights=[d.weight for d in dimensions]) + + i = next(child_generator) + + while sum(sizes) < min(width, sum_dimensions.preferred): + # Increase until we meet at least the 'preferred' size. + if sizes[i] < dimensions[i].preferred: + sizes[i] += 1 + i = next(child_generator) + + while sum(sizes) < min(width, sum_dimensions.max): + # Increase until we use all the available space. + if sizes[i] < dimensions[i].max: + sizes[i] += 1 + i = next(child_generator) + + return sizes + + def write_to_screen(self, cli, screen, mouse_handlers, write_position): + """ + Render the prompt to a `Screen` instance. + + :param screen: The :class:`~libs.prompt_toolkit.layout.screen.Screen` class + to which the output has to be written. + """ + if not self.children: + return + + sizes = self._divide_widths(cli, write_position.width) + + if self.report_dimensions_callback: + self.report_dimensions_callback(cli, sizes) + + # If there is not enough space. + if sizes is None: + self.window_too_small.write_to_screen( + cli, screen, mouse_handlers, write_position) + return + + # Calculate heights, take the largest possible, but not larger than write_position.extended_height. + heights = [child.preferred_height(cli, width, write_position.extended_height).preferred + for width, child in zip(sizes, self.children)] + height = max(write_position.height, min(write_position.extended_height, max(heights))) + + # Draw child panes. + ypos = write_position.ypos + xpos = write_position.xpos + + for s, c in zip(sizes, self.children): + c.write_to_screen(cli, screen, mouse_handlers, WritePosition(xpos, ypos, s, height)) + xpos += s + + def walk(self, cli): + """ Walk through children. """ + yield self + for c in self.children: + for i in c.walk(cli): + yield i + + +class FloatContainer(Container): + """ + Container which can contain another container for the background, as well + as a list of floating containers on top of it. + + Example Usage:: + + FloatContainer(content=Window(...), + floats=[ + Float(xcursor=True, + ycursor=True, + layout=CompletionMenu(...)) + ]) + """ + def __init__(self, content, floats): + assert isinstance(content, Container) + assert all(isinstance(f, Float) for f in floats) + + self.content = content + self.floats = floats + + def reset(self): + self.content.reset() + + for f in self.floats: + f.content.reset() + + def preferred_width(self, cli, write_position): + return self.content.preferred_width(cli, write_position) + + def preferred_height(self, cli, width, max_available_height): + """ + Return the preferred height of the float container. + (We don't care about the height of the floats, they should always fit + into the dimensions provided by the container.) + """ + return self.content.preferred_height(cli, width, max_available_height) + + def write_to_screen(self, cli, screen, mouse_handlers, write_position): + self.content.write_to_screen(cli, screen, mouse_handlers, write_position) + + for fl in self.floats: + # When a menu_position was given, use this instead of the cursor + # position. (These cursor positions are absolute, translate again + # relative to the write_position.) + # Note: This should be inside the for-loop, because one float could + # set the cursor position to be used for the next one. + cursor_position = screen.menu_position or screen.cursor_position + cursor_position = Point(x=cursor_position.x - write_position.xpos, + y=cursor_position.y - write_position.ypos) + + fl_width = fl.get_width(cli) + fl_height = fl.get_height(cli) + + # Left & width given. + if fl.left is not None and fl_width is not None: + xpos = fl.left + width = fl_width + # Left & right given -> calculate width. + elif fl.left is not None and fl.right is not None: + xpos = fl.left + width = write_position.width - fl.left - fl.right + # Width & right given -> calculate left. + elif fl_width is not None and fl.right is not None: + xpos = write_position.width - fl.right - fl_width + width = fl_width + elif fl.xcursor: + width = fl_width + if width is None: + width = fl.content.preferred_width(cli, write_position.width).preferred + width = min(write_position.width, width) + + xpos = cursor_position.x + if xpos + width > write_position.width: + xpos = max(0, write_position.width - width) + # Only width given -> center horizontally. + elif fl_width: + xpos = int((write_position.width - fl_width) / 2) + width = fl_width + # Otherwise, take preferred width from float content. + else: + width = fl.content.preferred_width(cli, write_position.width).preferred + + if fl.left is not None: + xpos = fl.left + elif fl.right is not None: + xpos = max(0, write_position.width - width - fl.right) + else: # Center horizontally. + xpos = max(0, int((write_position.width - width) / 2)) + + # Trim. + width = min(width, write_position.width - xpos) + + # Top & height given. + if fl.top is not None and fl_height is not None: + ypos = fl.top + height = fl_height + # Top & bottom given -> calculate height. + elif fl.top is not None and fl.bottom is not None: + ypos = fl.top + height = write_position.height - fl.top - fl.bottom + # Height & bottom given -> calculate top. + elif fl_height is not None and fl.bottom is not None: + ypos = write_position.height - fl_height - fl.bottom + height = fl_height + # Near cursor + elif fl.ycursor: + ypos = cursor_position.y + 1 + + height = fl_height + if height is None: + height = fl.content.preferred_height( + cli, width, write_position.extended_height).preferred + + # Reduce height if not enough space. (We can use the + # extended_height when the content requires it.) + if height > write_position.extended_height - ypos: + if write_position.extended_height - ypos + 1 >= ypos: + # When the space below the cursor is more than + # the space above, just reduce the height. + height = write_position.extended_height - ypos + else: + # Otherwise, fit the float above the cursor. + height = min(height, cursor_position.y) + ypos = cursor_position.y - height + + # Only height given -> center vertically. + elif fl_width: + ypos = int((write_position.height - fl_height) / 2) + height = fl_height + # Otherwise, take preferred height from content. + else: + height = fl.content.preferred_height( + cli, width, write_position.extended_height).preferred + + if fl.top is not None: + ypos = fl.top + elif fl.bottom is not None: + ypos = max(0, write_position.height - height - fl.bottom) + else: # Center vertically. + ypos = max(0, int((write_position.height - height) / 2)) + + # Trim. + height = min(height, write_position.height - ypos) + + # Write float. + # (xpos and ypos can be negative: a float can be partially visible.) + if height > 0 and width > 0: + wp = WritePosition(xpos=xpos + write_position.xpos, + ypos=ypos + write_position.ypos, + width=width, height=height) + + if not fl.hide_when_covering_content or self._area_is_empty(screen, wp): + fl.content.write_to_screen(cli, screen, mouse_handlers, wp) + + def _area_is_empty(self, screen, write_position): + """ + Return True when the area below the write position is still empty. + (For floats that should not hide content underneath.) + """ + wp = write_position + Transparent = Token.Transparent + + for y in range(wp.ypos, wp.ypos + wp.height): + if y in screen.data_buffer: + row = screen.data_buffer[y] + + for x in range(wp.xpos, wp.xpos + wp.width): + c = row[x] + if c.char != ' ' or c.token != Transparent: + return False + + return True + + def walk(self, cli): + """ Walk through children. """ + yield self + + for i in self.content.walk(cli): + yield i + + for f in self.floats: + for i in f.content.walk(cli): + yield i + + +class Float(object): + """ + Float for use in a :class:`.FloatContainer`. + + :param content: :class:`.Container` instance. + :param hide_when_covering_content: Hide the float when it covers content underneath. + """ + def __init__(self, top=None, right=None, bottom=None, left=None, + width=None, height=None, get_width=None, get_height=None, + xcursor=False, ycursor=False, content=None, + hide_when_covering_content=False): + assert isinstance(content, Container) + assert width is None or get_width is None + assert height is None or get_height is None + + self.left = left + self.right = right + self.top = top + self.bottom = bottom + + self._width = width + self._height = height + + self._get_width = get_width + self._get_height = get_height + + self.xcursor = xcursor + self.ycursor = ycursor + + self.content = content + self.hide_when_covering_content = hide_when_covering_content + + def get_width(self, cli): + if self._width: + return self._width + if self._get_width: + return self._get_width(cli) + + def get_height(self, cli): + if self._height: + return self._height + if self._get_height: + return self._get_height(cli) + + def __repr__(self): + return 'Float(content=%r)' % self.content + + +class WindowRenderInfo(object): + """ + Render information, for the last render time of this control. + It stores mapping information between the input buffers (in case of a + :class:`~libs.prompt_toolkit.layout.controls.BufferControl`) and the actual + render position on the output screen. + + (Could be used for implementation of the Vi 'H' and 'L' key bindings as + well as implementing mouse support.) + + :param ui_content: The original :class:`.UIContent` instance that contains + the whole input, without clipping. (ui_content) + :param horizontal_scroll: The horizontal scroll of the :class:`.Window` instance. + :param vertical_scroll: The vertical scroll of the :class:`.Window` instance. + :param window_width: The width of the window that displays the content, + without the margins. + :param window_height: The height of the window that displays the content. + :param configured_scroll_offsets: The scroll offsets as configured for the + :class:`Window` instance. + :param visible_line_to_row_col: Mapping that maps the row numbers on the + displayed screen (starting from zero for the first visible line) to + (row, col) tuples pointing to the row and column of the :class:`.UIContent`. + :param rowcol_to_yx: Mapping that maps (row, column) tuples representing + coordinates of the :class:`UIContent` to (y, x) absolute coordinates at + the rendered screen. + """ + def __init__(self, ui_content, horizontal_scroll, vertical_scroll, + window_width, window_height, + configured_scroll_offsets, + visible_line_to_row_col, rowcol_to_yx, + x_offset, y_offset, wrap_lines): + assert isinstance(ui_content, UIContent) + assert isinstance(horizontal_scroll, int) + assert isinstance(vertical_scroll, int) + assert isinstance(window_width, int) + assert isinstance(window_height, int) + assert isinstance(configured_scroll_offsets, ScrollOffsets) + assert isinstance(visible_line_to_row_col, dict) + assert isinstance(rowcol_to_yx, dict) + assert isinstance(x_offset, int) + assert isinstance(y_offset, int) + assert isinstance(wrap_lines, bool) + + self.ui_content = ui_content + self.vertical_scroll = vertical_scroll + self.window_width = window_width # Width without margins. + self.window_height = window_height + + self.configured_scroll_offsets = configured_scroll_offsets + self.visible_line_to_row_col = visible_line_to_row_col + self.wrap_lines = wrap_lines + + self._rowcol_to_yx = rowcol_to_yx # row/col from input to absolute y/x + # screen coordinates. + self._x_offset = x_offset + self._y_offset = y_offset + + @property + def visible_line_to_input_line(self): + return dict( + (visible_line, rowcol[0]) + for visible_line, rowcol in self.visible_line_to_row_col.items()) + + @property + def cursor_position(self): + """ + Return the cursor position coordinates, relative to the left/top corner + of the rendered screen. + """ + cpos = self.ui_content.cursor_position + y, x = self._rowcol_to_yx[cpos.y, cpos.x] + return Point(x=x - self._x_offset, y=y - self._y_offset) + + @property + def applied_scroll_offsets(self): + """ + Return a :class:`.ScrollOffsets` instance that indicates the actual + offset. This can be less than or equal to what's configured. E.g, when + the cursor is completely at the top, the top offset will be zero rather + than what's configured. + """ + if self.displayed_lines[0] == 0: + top = 0 + else: + # Get row where the cursor is displayed. + y = self.input_line_to_visible_line[self.ui_content.cursor_position.y] + top = min(y, self.configured_scroll_offsets.top) + + return ScrollOffsets( + top=top, + bottom=min(self.ui_content.line_count - self.displayed_lines[-1] - 1, + self.configured_scroll_offsets.bottom), + + # For left/right, it probably doesn't make sense to return something. + # (We would have to calculate the widths of all the lines and keep + # double width characters in mind.) + left=0, right=0) + + @property + def displayed_lines(self): + """ + List of all the visible rows. (Line numbers of the input buffer.) + The last line may not be entirely visible. + """ + return sorted(row for row, col in self.visible_line_to_row_col.values()) + + @property + def input_line_to_visible_line(self): + """ + Return the dictionary mapping the line numbers of the input buffer to + the lines of the screen. When a line spans several rows at the screen, + the first row appears in the dictionary. + """ + result = {} + for k, v in self.visible_line_to_input_line.items(): + if v in result: + result[v] = min(result[v], k) + else: + result[v] = k + return result + + def first_visible_line(self, after_scroll_offset=False): + """ + Return the line number (0 based) of the input document that corresponds + with the first visible line. + """ + if after_scroll_offset: + return self.displayed_lines[self.applied_scroll_offsets.top] + else: + return self.displayed_lines[0] + + def last_visible_line(self, before_scroll_offset=False): + """ + Like `first_visible_line`, but for the last visible line. + """ + if before_scroll_offset: + return self.displayed_lines[-1 - self.applied_scroll_offsets.bottom] + else: + return self.displayed_lines[-1] + + def center_visible_line(self, before_scroll_offset=False, + after_scroll_offset=False): + """ + Like `first_visible_line`, but for the center visible line. + """ + return (self.first_visible_line(after_scroll_offset) + + (self.last_visible_line(before_scroll_offset) - + self.first_visible_line(after_scroll_offset)) // 2 + ) + + @property + def content_height(self): + """ + The full height of the user control. + """ + return self.ui_content.line_count + + @property + def full_height_visible(self): + """ + True when the full height is visible (There is no vertical scroll.) + """ + return self.vertical_scroll == 0 and self.last_visible_line() == self.content_height + + @property + def top_visible(self): + """ + True when the top of the buffer is visible. + """ + return self.vertical_scroll == 0 + + @property + def bottom_visible(self): + """ + True when the bottom of the buffer is visible. + """ + return self.last_visible_line() == self.content_height - 1 + + @property + def vertical_scroll_percentage(self): + """ + Vertical scroll as a percentage. (0 means: the top is visible, + 100 means: the bottom is visible.) + """ + if self.bottom_visible: + return 100 + else: + return (100 * self.vertical_scroll // self.content_height) + + def get_height_for_line(self, lineno): + """ + Return the height of the given line. + (The height that it would take, if this line became visible.) + """ + if self.wrap_lines: + return self.ui_content.get_height_for_line(lineno, self.window_width) + else: + return 1 + + +class ScrollOffsets(object): + """ + Scroll offsets for the :class:`.Window` class. + + Note that left/right offsets only make sense if line wrapping is disabled. + """ + def __init__(self, top=0, bottom=0, left=0, right=0): + assert isinstance(top, Integer) + assert isinstance(bottom, Integer) + assert isinstance(left, Integer) + assert isinstance(right, Integer) + + self._top = top + self._bottom = bottom + self._left = left + self._right = right + + @property + def top(self): + return int(self._top) + + @property + def bottom(self): + return int(self._bottom) + + @property + def left(self): + return int(self._left) + + @property + def right(self): + return int(self._right) + + def __repr__(self): + return 'ScrollOffsets(top=%r, bottom=%r, left=%r, right=%r)' % ( + self.top, self.bottom, self.left, self.right) + + +class ColorColumn(object): + def __init__(self, position, token=Token.ColorColumn): + self.position = position + self.token = token + + +_in_insert_mode = ViInsertMode() | EmacsInsertMode() + + +class Window(Container): + """ + Container that holds a control. + + :param content: :class:`~libs.prompt_toolkit.layout.controls.UIControl` instance. + :param width: :class:`~libs.prompt_toolkit.layout.dimension.LayoutDimension` instance. + :param height: :class:`~libs.prompt_toolkit.layout.dimension.LayoutDimension` instance. + :param get_width: callable which takes a `CommandLineInterface` and returns a `LayoutDimension`. + :param get_height: callable which takes a `CommandLineInterface` and returns a `LayoutDimension`. + :param dont_extend_width: When `True`, don't take up more width then the + preferred width reported by the control. + :param dont_extend_height: When `True`, don't take up more width then the + preferred height reported by the control. + :param left_margins: A list of :class:`~libs.prompt_toolkit.layout.margins.Margin` + instance to be displayed on the left. For instance: + :class:`~libs.prompt_toolkit.layout.margins.NumberredMargin` can be one of + them in order to show line numbers. + :param right_margins: Like `left_margins`, but on the other side. + :param scroll_offsets: :class:`.ScrollOffsets` instance, representing the + preferred amount of lines/columns to be always visible before/after the + cursor. When both top and bottom are a very high number, the cursor + will be centered vertically most of the time. + :param allow_scroll_beyond_bottom: A `bool` or + :class:`~libs.prompt_toolkit.filters.CLIFilter` instance. When True, allow + scrolling so far, that the top part of the content is not visible + anymore, while there is still empty space available at the bottom of + the window. In the Vi editor for instance, this is possible. You will + see tildes while the top part of the body is hidden. + :param wrap_lines: A `bool` or :class:`~libs.prompt_toolkit.filters.CLIFilter` + instance. When True, don't scroll horizontally, but wrap lines instead. + :param get_vertical_scroll: Callable that takes this window + instance as input and returns a preferred vertical scroll. + (When this is `None`, the scroll is only determined by the last and + current cursor position.) + :param get_horizontal_scroll: Callable that takes this window + instance as input and returns a preferred vertical scroll. + :param always_hide_cursor: A `bool` or + :class:`~libs.prompt_toolkit.filters.CLIFilter` instance. When True, never + display the cursor, even when the user control specifies a cursor + position. + :param cursorline: A `bool` or :class:`~libs.prompt_toolkit.filters.CLIFilter` + instance. When True, display a cursorline. + :param cursorcolumn: A `bool` or :class:`~libs.prompt_toolkit.filters.CLIFilter` + instance. When True, display a cursorcolumn. + :param get_colorcolumns: A callable that takes a `CommandLineInterface` and + returns a a list of :class:`.ColorColumn` instances that describe the + columns to be highlighted. + :param cursorline_token: The token to be used for highlighting the current line, + if `cursorline` is True. + :param cursorcolumn_token: The token to be used for highlighting the current line, + if `cursorcolumn` is True. + """ + def __init__(self, content, width=None, height=None, get_width=None, + get_height=None, dont_extend_width=False, dont_extend_height=False, + left_margins=None, right_margins=None, scroll_offsets=None, + allow_scroll_beyond_bottom=False, wrap_lines=False, + get_vertical_scroll=None, get_horizontal_scroll=None, always_hide_cursor=False, + cursorline=False, cursorcolumn=False, get_colorcolumns=None, + cursorline_token=Token.CursorLine, cursorcolumn_token=Token.CursorColumn): + assert isinstance(content, UIControl) + assert width is None or isinstance(width, LayoutDimension) + assert height is None or isinstance(height, LayoutDimension) + assert get_width is None or callable(get_width) + assert get_height is None or callable(get_height) + assert width is None or get_width is None + assert height is None or get_height is None + assert scroll_offsets is None or isinstance(scroll_offsets, ScrollOffsets) + assert left_margins is None or all(isinstance(m, Margin) for m in left_margins) + assert right_margins is None or all(isinstance(m, Margin) for m in right_margins) + assert get_vertical_scroll is None or callable(get_vertical_scroll) + assert get_horizontal_scroll is None or callable(get_horizontal_scroll) + assert get_colorcolumns is None or callable(get_colorcolumns) + + self.allow_scroll_beyond_bottom = to_cli_filter(allow_scroll_beyond_bottom) + self.always_hide_cursor = to_cli_filter(always_hide_cursor) + self.wrap_lines = to_cli_filter(wrap_lines) + self.cursorline = to_cli_filter(cursorline) + self.cursorcolumn = to_cli_filter(cursorcolumn) + + self.content = content + self.dont_extend_width = dont_extend_width + self.dont_extend_height = dont_extend_height + self.left_margins = left_margins or [] + self.right_margins = right_margins or [] + self.scroll_offsets = scroll_offsets or ScrollOffsets() + self.get_vertical_scroll = get_vertical_scroll + self.get_horizontal_scroll = get_horizontal_scroll + self._width = get_width or (lambda cli: width) + self._height = get_height or (lambda cli: height) + self.get_colorcolumns = get_colorcolumns or (lambda cli: []) + self.cursorline_token = cursorline_token + self.cursorcolumn_token = cursorcolumn_token + + # Cache for the screens generated by the margin. + self._ui_content_cache = SimpleCache(maxsize=8) + self._margin_width_cache = SimpleCache(maxsize=1) + + self.reset() + + def __repr__(self): + return 'Window(content=%r)' % self.content + + def reset(self): + self.content.reset() + + #: Scrolling position of the main content. + self.vertical_scroll = 0 + self.horizontal_scroll = 0 + + # Vertical scroll 2: this is the vertical offset that a line is + # scrolled if a single line (the one that contains the cursor) consumes + # all of the vertical space. + self.vertical_scroll_2 = 0 + + #: Keep render information (mappings between buffer input and render + #: output.) + self.render_info = None + + def _get_margin_width(self, cli, margin): + """ + Return the width for this margin. + (Calculate only once per render time.) + """ + # Margin.get_width, needs to have a UIContent instance. + def get_ui_content(): + return self._get_ui_content(cli, width=0, height=0) + + def get_width(): + return margin.get_width(cli, get_ui_content) + + key = (margin, cli.render_counter) + return self._margin_width_cache.get(key, get_width) + + def preferred_width(self, cli, max_available_width): + # Calculate the width of the margin. + total_margin_width = sum(self._get_margin_width(cli, m) for m in + self.left_margins + self.right_margins) + + # Window of the content. (Can be `None`.) + preferred_width = self.content.preferred_width( + cli, max_available_width - total_margin_width) + + if preferred_width is not None: + # Include width of the margins. + preferred_width += total_margin_width + + # Merge. + return self._merge_dimensions( + dimension=self._width(cli), + preferred=preferred_width, + dont_extend=self.dont_extend_width) + + def preferred_height(self, cli, width, max_available_height): + total_margin_width = sum(self._get_margin_width(cli, m) for m in + self.left_margins + self.right_margins) + wrap_lines = self.wrap_lines(cli) + + return self._merge_dimensions( + dimension=self._height(cli), + preferred=self.content.preferred_height( + cli, width - total_margin_width, max_available_height, wrap_lines), + dont_extend=self.dont_extend_height) + + @staticmethod + def _merge_dimensions(dimension, preferred=None, dont_extend=False): + """ + Take the LayoutDimension from this `Window` class and the received + preferred size from the `UIControl` and return a `LayoutDimension` to + report to the parent container. + """ + dimension = dimension or LayoutDimension() + + # When a preferred dimension was explicitly given to the Window, + # ignore the UIControl. + if dimension.preferred_specified: + preferred = dimension.preferred + + # When a 'preferred' dimension is given by the UIControl, make sure + # that it stays within the bounds of the Window. + if preferred is not None: + if dimension.max: + preferred = min(preferred, dimension.max) + + if dimension.min: + preferred = max(preferred, dimension.min) + + # When a `dont_extend` flag has been given, use the preferred dimension + # also as the max dimension. + if dont_extend and preferred is not None: + max_ = min(dimension.max, preferred) + else: + max_ = dimension.max + + return LayoutDimension( + min=dimension.min, max=max_, + preferred=preferred, weight=dimension.weight) + + def _get_ui_content(self, cli, width, height): + """ + Create a `UIContent` instance. + """ + def get_content(): + return self.content.create_content(cli, width=width, height=height) + + key = (cli.render_counter, width, height) + return self._ui_content_cache.get(key, get_content) + + def _get_digraph_char(self, cli): + " Return `False`, or the Digraph symbol to be used. " + if cli.quoted_insert: + return '^' + if cli.vi_state.waiting_for_digraph: + if cli.vi_state.digraph_symbol1: + return cli.vi_state.digraph_symbol1 + return '?' + return False + + def write_to_screen(self, cli, screen, mouse_handlers, write_position): + """ + Write window to screen. This renders the user control, the margins and + copies everything over to the absolute position at the given screen. + """ + # Calculate margin sizes. + left_margin_widths = [self._get_margin_width(cli, m) for m in self.left_margins] + right_margin_widths = [self._get_margin_width(cli, m) for m in self.right_margins] + total_margin_width = sum(left_margin_widths + right_margin_widths) + + # Render UserControl. + ui_content = self.content.create_content( + cli, write_position.width - total_margin_width, write_position.height) + assert isinstance(ui_content, UIContent) + + # Scroll content. + wrap_lines = self.wrap_lines(cli) + scroll_func = self._scroll_when_linewrapping if wrap_lines else self._scroll_without_linewrapping + + scroll_func( + ui_content, write_position.width - total_margin_width, write_position.height, cli) + + # Write body + visible_line_to_row_col, rowcol_to_yx = self._copy_body( + cli, ui_content, screen, write_position, + sum(left_margin_widths), write_position.width - total_margin_width, + self.vertical_scroll, self.horizontal_scroll, + has_focus=self.content.has_focus(cli), + wrap_lines=wrap_lines, highlight_lines=True, + vertical_scroll_2=self.vertical_scroll_2, + always_hide_cursor=self.always_hide_cursor(cli)) + + # Remember render info. (Set before generating the margins. They need this.) + x_offset=write_position.xpos + sum(left_margin_widths) + y_offset=write_position.ypos + + self.render_info = WindowRenderInfo( + ui_content=ui_content, + horizontal_scroll=self.horizontal_scroll, + vertical_scroll=self.vertical_scroll, + window_width=write_position.width - total_margin_width, + window_height=write_position.height, + configured_scroll_offsets=self.scroll_offsets, + visible_line_to_row_col=visible_line_to_row_col, + rowcol_to_yx=rowcol_to_yx, + x_offset=x_offset, + y_offset=y_offset, + wrap_lines=wrap_lines) + + # Set mouse handlers. + def mouse_handler(cli, mouse_event): + """ Wrapper around the mouse_handler of the `UIControl` that turns + screen coordinates into line coordinates. """ + # Find row/col position first. + yx_to_rowcol = dict((v, k) for k, v in rowcol_to_yx.items()) + y = mouse_event.position.y + x = mouse_event.position.x + + # If clicked below the content area, look for a position in the + # last line instead. + max_y = write_position.ypos + len(visible_line_to_row_col) - 1 + y = min(max_y, y) + + while x >= 0: + try: + row, col = yx_to_rowcol[y, x] + except KeyError: + # Try again. (When clicking on the right side of double + # width characters, or on the right side of the input.) + x -= 1 + else: + # Found position, call handler of UIControl. + result = self.content.mouse_handler( + cli, MouseEvent(position=Point(x=col, y=row), + event_type=mouse_event.event_type)) + break + else: + # nobreak. + # (No x/y coordinate found for the content. This happens in + # case of a FillControl, that only specifies a background, but + # doesn't have a content. Report (0,0) instead.) + result = self.content.mouse_handler( + cli, MouseEvent(position=Point(x=0, y=0), + event_type=mouse_event.event_type)) + + # If it returns NotImplemented, handle it here. + if result == NotImplemented: + return self._mouse_handler(cli, mouse_event) + + return result + + mouse_handlers.set_mouse_handler_for_range( + x_min=write_position.xpos + sum(left_margin_widths), + x_max=write_position.xpos + write_position.width - total_margin_width, + y_min=write_position.ypos, + y_max=write_position.ypos + write_position.height, + handler=mouse_handler) + + # Render and copy margins. + move_x = 0 + + def render_margin(m, width): + " Render margin. Return `Screen`. " + # Retrieve margin tokens. + tokens = m.create_margin(cli, self.render_info, width, write_position.height) + + # Turn it into a UIContent object. + # already rendered those tokens using this size.) + return TokenListControl.static(tokens).create_content( + cli, width + 1, write_position.height) + + for m, width in zip(self.left_margins, left_margin_widths): + # Create screen for margin. + margin_screen = render_margin(m, width) + + # Copy and shift X. + self._copy_margin(cli, margin_screen, screen, write_position, move_x, width) + move_x += width + + move_x = write_position.width - sum(right_margin_widths) + + for m, width in zip(self.right_margins, right_margin_widths): + # Create screen for margin. + margin_screen = render_margin(m, width) + + # Copy and shift X. + self._copy_margin(cli, margin_screen, screen, write_position, move_x, width) + move_x += width + + def _copy_body(self, cli, ui_content, new_screen, write_position, move_x, + width, vertical_scroll=0, horizontal_scroll=0, + has_focus=False, wrap_lines=False, highlight_lines=False, + vertical_scroll_2=0, always_hide_cursor=False): + """ + Copy the UIContent into the output screen. + """ + xpos = write_position.xpos + move_x + ypos = write_position.ypos + line_count = ui_content.line_count + new_buffer = new_screen.data_buffer + empty_char = _CHAR_CACHE['', Token] + ZeroWidthEscape = Token.ZeroWidthEscape + + # Map visible line number to (row, col) of input. + # 'col' will always be zero if line wrapping is off. + visible_line_to_row_col = {} + rowcol_to_yx = {} # Maps (row, col) from the input to (y, x) screen coordinates. + + # Fill background with default_char first. + default_char = ui_content.default_char + + if default_char: + for y in range(ypos, ypos + write_position.height): + new_buffer_row = new_buffer[y] + for x in range(xpos, xpos + width): + new_buffer_row[x] = default_char + + # Copy content. + def copy(): + y = - vertical_scroll_2 + lineno = vertical_scroll + + while y < write_position.height and lineno < line_count: + # Take the next line and copy it in the real screen. + line = ui_content.get_line(lineno) + + col = 0 + x = -horizontal_scroll + + visible_line_to_row_col[y] = (lineno, horizontal_scroll) + new_buffer_row = new_buffer[y + ypos] + + for token, text in line: + # Remember raw VT escape sequences. (E.g. FinalTerm's + # escape sequences.) + if token == ZeroWidthEscape: + new_screen.zero_width_escapes[y + ypos][x + xpos] += text + continue + + for c in text: + char = _CHAR_CACHE[c, token] + char_width = char.width + + # Wrap when the line width is exceeded. + if wrap_lines and x + char_width > width: + visible_line_to_row_col[y + 1] = ( + lineno, visible_line_to_row_col[y][1] + x) + y += 1 + x = -horizontal_scroll # This would be equal to zero. + # (horizontal_scroll=0 when wrap_lines.) + new_buffer_row = new_buffer[y + ypos] + + if y >= write_position.height: + return y # Break out of all for loops. + + # Set character in screen and shift 'x'. + if x >= 0 and y >= 0 and x < write_position.width: + new_buffer_row[x + xpos] = char + + # When we print a multi width character, make sure + # to erase the neighbous positions in the screen. + # (The empty string if different from everything, + # so next redraw this cell will repaint anyway.) + if char_width > 1: + for i in range(1, char_width): + new_buffer_row[x + xpos + i] = empty_char + + # If this is a zero width characters, then it's + # probably part of a decomposed unicode character. + # See: https://en.wikipedia.org/wiki/Unicode_equivalence + # Merge it in the previous cell. + elif char_width == 0 and x - 1 >= 0: + prev_char = new_buffer_row[x + xpos - 1] + char2 = _CHAR_CACHE[prev_char.char + c, prev_char.token] + new_buffer_row[x + xpos - 1] = char2 + + # Keep track of write position for each character. + rowcol_to_yx[lineno, col] = (y + ypos, x + xpos) + + col += 1 + x += char_width + + lineno += 1 + y += 1 + return y + + y = copy() + + def cursor_pos_to_screen_pos(row, col): + " Translate row/col from UIContent to real Screen coordinates. " + try: + y, x = rowcol_to_yx[row, col] + except KeyError: + # Normally this should never happen. (It is a bug, if it happens.) + # But to be sure, return (0, 0) + return Point(y=0, x=0) + + # raise ValueError( + # 'Invalid position. row=%r col=%r, vertical_scroll=%r, ' + # 'horizontal_scroll=%r, height=%r' % + # (row, col, vertical_scroll, horizontal_scroll, write_position.height)) + else: + return Point(y=y, x=x) + + # Set cursor and menu positions. + if ui_content.cursor_position: + screen_cursor_position = cursor_pos_to_screen_pos( + ui_content.cursor_position.y, ui_content.cursor_position.x) + + if has_focus: + new_screen.cursor_position = screen_cursor_position + + if always_hide_cursor: + new_screen.show_cursor = False + else: + new_screen.show_cursor = ui_content.show_cursor + + self._highlight_digraph(cli, new_screen) + + if highlight_lines: + self._highlight_cursorlines( + cli, new_screen, screen_cursor_position, xpos, ypos, width, + write_position.height) + + # Draw input characters from the input processor queue. + if has_focus and ui_content.cursor_position: + self._show_input_processor_key_buffer(cli, new_screen) + + # Set menu position. + if not new_screen.menu_position and ui_content.menu_position: + new_screen.menu_position = cursor_pos_to_screen_pos( + ui_content.menu_position.y, ui_content.menu_position.x) + + # Update output screne height. + new_screen.height = max(new_screen.height, ypos + write_position.height) + + return visible_line_to_row_col, rowcol_to_yx + + def _highlight_digraph(self, cli, new_screen): + """ + When we are in Vi digraph mode, put a question mark underneath the + cursor. + """ + digraph_char = self._get_digraph_char(cli) + if digraph_char: + cpos = new_screen.cursor_position + new_screen.data_buffer[cpos.y][cpos.x] = \ + _CHAR_CACHE[digraph_char, Token.Digraph] + + def _show_input_processor_key_buffer(self, cli, new_screen): + """ + When the user is typing a key binding that consists of several keys, + display the last pressed key if the user is in insert mode and the key + is meaningful to be displayed. + E.g. Some people want to bind 'jj' to escape in Vi insert mode. But the + first 'j' needs to be displayed in order to get some feedback. + """ + key_buffer = cli.input_processor.key_buffer + + if key_buffer and _in_insert_mode(cli) and not cli.is_done: + # The textual data for the given key. (Can be a VT100 escape + # sequence.) + data = key_buffer[-1].data + + # Display only if this is a 1 cell width character. + if get_cwidth(data) == 1: + cpos = new_screen.cursor_position + new_screen.data_buffer[cpos.y][cpos.x] = \ + _CHAR_CACHE[data, Token.PartialKeyBinding] + + def _highlight_cursorlines(self, cli, new_screen, cpos, x, y, width, height): + """ + Highlight cursor row/column. + """ + cursor_line_token = (':', ) + self.cursorline_token + cursor_column_token = (':', ) + self.cursorcolumn_token + + data_buffer = new_screen.data_buffer + + # Highlight cursor line. + if self.cursorline(cli): + row = data_buffer[cpos.y] + for x in range(x, x + width): + original_char = row[x] + row[x] = _CHAR_CACHE[ + original_char.char, original_char.token + cursor_line_token] + + # Highlight cursor column. + if self.cursorcolumn(cli): + for y2 in range(y, y + height): + row = data_buffer[y2] + original_char = row[cpos.x] + row[cpos.x] = _CHAR_CACHE[ + original_char.char, original_char.token + cursor_column_token] + + # Highlight color columns + for cc in self.get_colorcolumns(cli): + assert isinstance(cc, ColorColumn) + color_column_token = (':', ) + cc.token + column = cc.position + + for y2 in range(y, y + height): + row = data_buffer[y2] + original_char = row[column] + row[column] = _CHAR_CACHE[ + original_char.char, original_char.token + color_column_token] + + def _copy_margin(self, cli, lazy_screen, new_screen, write_position, move_x, width): + """ + Copy characters from the margin screen to the real screen. + """ + xpos = write_position.xpos + move_x + ypos = write_position.ypos + + margin_write_position = WritePosition(xpos, ypos, width, write_position.height) + self._copy_body(cli, lazy_screen, new_screen, margin_write_position, 0, width) + + def _scroll_when_linewrapping(self, ui_content, width, height, cli): + """ + Scroll to make sure the cursor position is visible and that we maintain + the requested scroll offset. + + Set `self.horizontal_scroll/vertical_scroll`. + """ + scroll_offsets_bottom = self.scroll_offsets.bottom + scroll_offsets_top = self.scroll_offsets.top + + # We don't have horizontal scrolling. + self.horizontal_scroll = 0 + + # If the current line consumes more than the whole window height, + # then we have to scroll vertically inside this line. (We don't take + # the scroll offsets into account for this.) + # Also, ignore the scroll offsets in this case. Just set the vertical + # scroll to this line. + if ui_content.get_height_for_line(ui_content.cursor_position.y, width) > height - scroll_offsets_top: + # Calculate the height of the text before the cursor, with the line + # containing the cursor included, and the character belowe the + # cursor included as well. + line = explode_tokens(ui_content.get_line(ui_content.cursor_position.y)) + text_before_cursor = token_list_to_text(line[:ui_content.cursor_position.x + 1]) + text_before_height = UIContent.get_height_for_text(text_before_cursor, width) + + # Adjust scroll offset. + self.vertical_scroll = ui_content.cursor_position.y + self.vertical_scroll_2 = min(text_before_height - 1, self.vertical_scroll_2) + self.vertical_scroll_2 = max(0, text_before_height - height, self.vertical_scroll_2) + return + else: + self.vertical_scroll_2 = 0 + + # Current line doesn't consume the whole height. Take scroll offsets into account. + def get_min_vertical_scroll(): + # Make sure that the cursor line is not below the bottom. + # (Calculate how many lines can be shown between the cursor and the .) + used_height = 0 + prev_lineno = ui_content.cursor_position.y + + for lineno in range(ui_content.cursor_position.y, -1, -1): + used_height += ui_content.get_height_for_line(lineno, width) + + if used_height > height - scroll_offsets_bottom: + return prev_lineno + else: + prev_lineno = lineno + return 0 + + def get_max_vertical_scroll(): + # Make sure that the cursor line is not above the top. + prev_lineno = ui_content.cursor_position.y + used_height = 0 + + for lineno in range(ui_content.cursor_position.y - 1, -1, -1): + used_height += ui_content.get_height_for_line(lineno, width) + + if used_height > scroll_offsets_top: + return prev_lineno + else: + prev_lineno = lineno + return prev_lineno + + def get_topmost_visible(): + """ + Calculate the upper most line that can be visible, while the bottom + is still visible. We should not allow scroll more than this if + `allow_scroll_beyond_bottom` is false. + """ + prev_lineno = ui_content.line_count - 1 + used_height = 0 + for lineno in range(ui_content.line_count - 1, -1, -1): + used_height += ui_content.get_height_for_line(lineno, width) + if used_height > height: + return prev_lineno + else: + prev_lineno = lineno + return prev_lineno + + # Scroll vertically. (Make sure that the whole line which contains the + # cursor is visible. + topmost_visible = get_topmost_visible() + + # Note: the `min(topmost_visible, ...)` is to make sure that we + # don't require scrolling up because of the bottom scroll offset, + # when we are at the end of the document. + self.vertical_scroll = max(self.vertical_scroll, min(topmost_visible, get_min_vertical_scroll())) + self.vertical_scroll = min(self.vertical_scroll, get_max_vertical_scroll()) + + # Disallow scrolling beyond bottom? + if not self.allow_scroll_beyond_bottom(cli): + self.vertical_scroll = min(self.vertical_scroll, topmost_visible) + + def _scroll_without_linewrapping(self, ui_content, width, height, cli): + """ + Scroll to make sure the cursor position is visible and that we maintain + the requested scroll offset. + + Set `self.horizontal_scroll/vertical_scroll`. + """ + cursor_position = ui_content.cursor_position or Point(0, 0) + + # Without line wrapping, we will never have to scroll vertically inside + # a single line. + self.vertical_scroll_2 = 0 + + if ui_content.line_count == 0: + self.vertical_scroll = 0 + self.horizontal_scroll = 0 + return + else: + current_line_text = token_list_to_text(ui_content.get_line(cursor_position.y)) + + def do_scroll(current_scroll, scroll_offset_start, scroll_offset_end, + cursor_pos, window_size, content_size): + " Scrolling algorithm. Used for both horizontal and vertical scrolling. " + # Calculate the scroll offset to apply. + # This can obviously never be more than have the screen size. Also, when the + # cursor appears at the top or bottom, we don't apply the offset. + scroll_offset_start = int(min(scroll_offset_start, window_size / 2, cursor_pos)) + scroll_offset_end = int(min(scroll_offset_end, window_size / 2, + content_size - 1 - cursor_pos)) + + # Prevent negative scroll offsets. + if current_scroll < 0: + current_scroll = 0 + + # Scroll back if we scrolled to much and there's still space to show more of the document. + if (not self.allow_scroll_beyond_bottom(cli) and + current_scroll > content_size - window_size): + current_scroll = max(0, content_size - window_size) + + # Scroll up if cursor is before visible part. + if current_scroll > cursor_pos - scroll_offset_start: + current_scroll = max(0, cursor_pos - scroll_offset_start) + + # Scroll down if cursor is after visible part. + if current_scroll < (cursor_pos + 1) - window_size + scroll_offset_end: + current_scroll = (cursor_pos + 1) - window_size + scroll_offset_end + + return current_scroll + + # When a preferred scroll is given, take that first into account. + if self.get_vertical_scroll: + self.vertical_scroll = self.get_vertical_scroll(self) + assert isinstance(self.vertical_scroll, int) + if self.get_horizontal_scroll: + self.horizontal_scroll = self.get_horizontal_scroll(self) + assert isinstance(self.horizontal_scroll, int) + + # Update horizontal/vertical scroll to make sure that the cursor + # remains visible. + offsets = self.scroll_offsets + + self.vertical_scroll = do_scroll( + current_scroll=self.vertical_scroll, + scroll_offset_start=offsets.top, + scroll_offset_end=offsets.bottom, + cursor_pos=ui_content.cursor_position.y, + window_size=height, + content_size=ui_content.line_count) + + self.horizontal_scroll = do_scroll( + current_scroll=self.horizontal_scroll, + scroll_offset_start=offsets.left, + scroll_offset_end=offsets.right, + cursor_pos=get_cwidth(current_line_text[:ui_content.cursor_position.x]), + window_size=width, + # We can only analyse the current line. Calculating the width off + # all the lines is too expensive. + content_size=max(get_cwidth(current_line_text), self.horizontal_scroll + width)) + + def _mouse_handler(self, cli, mouse_event): + """ + Mouse handler. Called when the UI control doesn't handle this + particular event. + """ + if mouse_event.event_type == MouseEventType.SCROLL_DOWN: + self._scroll_down(cli) + elif mouse_event.event_type == MouseEventType.SCROLL_UP: + self._scroll_up(cli) + + def _scroll_down(self, cli): + " Scroll window down. " + info = self.render_info + + if self.vertical_scroll < info.content_height - info.window_height: + if info.cursor_position.y <= info.configured_scroll_offsets.top: + self.content.move_cursor_down(cli) + + self.vertical_scroll += 1 + + def _scroll_up(self, cli): + " Scroll window up. " + info = self.render_info + + if info.vertical_scroll > 0: + # TODO: not entirely correct yet in case of line wrapping and long lines. + if info.cursor_position.y >= info.window_height - 1 - info.configured_scroll_offsets.bottom: + self.content.move_cursor_up(cli) + + self.vertical_scroll -= 1 + + def walk(self, cli): + # Only yield self. A window doesn't have children. + yield self + + +class ConditionalContainer(Container): + """ + Wrapper around any other container that can change the visibility. The + received `filter` determines whether the given container should be + displayed or not. + + :param content: :class:`.Container` instance. + :param filter: :class:`~libs.prompt_toolkit.filters.CLIFilter` instance. + """ + def __init__(self, content, filter): + assert isinstance(content, Container) + + self.content = content + self.filter = to_cli_filter(filter) + + def __repr__(self): + return 'ConditionalContainer(%r, filter=%r)' % (self.content, self.filter) + + def reset(self): + self.content.reset() + + def preferred_width(self, cli, max_available_width): + if self.filter(cli): + return self.content.preferred_width(cli, max_available_width) + else: + return LayoutDimension.exact(0) + + def preferred_height(self, cli, width, max_available_height): + if self.filter(cli): + return self.content.preferred_height(cli, width, max_available_height) + else: + return LayoutDimension.exact(0) + + def write_to_screen(self, cli, screen, mouse_handlers, write_position): + if self.filter(cli): + return self.content.write_to_screen(cli, screen, mouse_handlers, write_position) + + def walk(self, cli): + return self.content.walk(cli) + + +# Deprecated alias for 'Container'. +Layout = Container diff --git a/src/libs/prompt_toolkit/layout/controls.py b/src/libs/prompt_toolkit/layout/controls.py new file mode 100644 index 0000000..89bcb96 --- /dev/null +++ b/src/libs/prompt_toolkit/layout/controls.py @@ -0,0 +1,730 @@ +""" +User interface Controls for the layout. +""" +from __future__ import unicode_literals + +from abc import ABCMeta, abstractmethod +from collections import namedtuple +from six import with_metaclass +from six.moves import range + +from libs.prompt_toolkit.cache import SimpleCache +from libs.prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER +from libs.prompt_toolkit.filters import to_cli_filter +from libs.prompt_toolkit.mouse_events import MouseEventType +from libs.prompt_toolkit.search_state import SearchState +from libs.prompt_toolkit.selection import SelectionType +from libs.prompt_toolkit.token import Token +from libs.prompt_toolkit.utils import get_cwidth + +from .lexers import Lexer, SimpleLexer +from .processors import Processor +from .screen import Char, Point +from .utils import token_list_width, split_lines, token_list_to_text + +import six +import time + + +__all__ = ( + 'BufferControl', + 'FillControl', + 'TokenListControl', + 'UIControl', + 'UIContent', +) + + +class UIControl(with_metaclass(ABCMeta, object)): + """ + Base class for all user interface controls. + """ + def reset(self): + # Default reset. (Doesn't have to be implemented.) + pass + + def preferred_width(self, cli, max_available_width): + return None + + def preferred_height(self, cli, width, max_available_height, wrap_lines): + return None + + def has_focus(self, cli): + """ + Return ``True`` when this user control has the focus. + + If so, the cursor will be displayed according to the cursor position + reported by :meth:`.UIControl.create_content`. If the created content + has the property ``show_cursor=False``, the cursor will be hidden from + the output. + """ + return False + + @abstractmethod + def create_content(self, cli, width, height): + """ + Generate the content for this user control. + + Returns a :class:`.UIContent` instance. + """ + + def mouse_handler(self, cli, mouse_event): + """ + Handle mouse events. + + When `NotImplemented` is returned, it means that the given event is not + handled by the `UIControl` itself. The `Window` or key bindings can + decide to handle this event as scrolling or changing focus. + + :param cli: `CommandLineInterface` instance. + :param mouse_event: `MouseEvent` instance. + """ + return NotImplemented + + def move_cursor_down(self, cli): + """ + Request to move the cursor down. + This happens when scrolling down and the cursor is completely at the + top. + """ + + def move_cursor_up(self, cli): + """ + Request to move the cursor up. + """ + + +class UIContent(object): + """ + Content generated by a user control. This content consists of a list of + lines. + + :param get_line: Callable that returns the current line. This is a list of + (Token, text) tuples. + :param line_count: The number of lines. + :param cursor_position: a :class:`.Point` for the cursor position. + :param menu_position: a :class:`.Point` for the menu position. + :param show_cursor: Make the cursor visible. + :param default_char: The default :class:`.Char` for filling the background. + """ + def __init__(self, get_line=None, line_count=0, + cursor_position=None, menu_position=None, show_cursor=True, + default_char=None): + assert callable(get_line) + assert isinstance(line_count, six.integer_types) + assert cursor_position is None or isinstance(cursor_position, Point) + assert menu_position is None or isinstance(menu_position, Point) + assert default_char is None or isinstance(default_char, Char) + + self.get_line = get_line + self.line_count = line_count + self.cursor_position = cursor_position or Point(0, 0) + self.menu_position = menu_position + self.show_cursor = show_cursor + self.default_char = default_char + + # Cache for line heights. Maps (lineno, width) -> height. + self._line_heights = {} + + def __getitem__(self, lineno): + " Make it iterable (iterate line by line). " + if lineno < self.line_count: + return self.get_line(lineno) + else: + raise IndexError + + def get_height_for_line(self, lineno, width): + """ + Return the height that a given line would need if it is rendered in a + space with the given width. + """ + try: + return self._line_heights[lineno, width] + except KeyError: + text = token_list_to_text(self.get_line(lineno)) + result = self.get_height_for_text(text, width) + + # Cache and return + self._line_heights[lineno, width] = result + return result + + @staticmethod + def get_height_for_text(text, width): + # Get text width for this line. + line_width = get_cwidth(text) + + # Calculate height. + try: + quotient, remainder = divmod(line_width, width) + except ZeroDivisionError: + # Return something very big. + # (This can happen, when the Window gets very small.) + return 10 ** 10 + else: + if remainder: + quotient += 1 # Like math.ceil. + return max(1, quotient) + + +class TokenListControl(UIControl): + """ + Control that displays a list of (Token, text) tuples. + (It's mostly optimized for rather small widgets, like toolbars, menus, etc...) + + Mouse support: + + The list of tokens can also contain tuples of three items, looking like: + (Token, text, handler). When mouse support is enabled and the user + clicks on this token, then the given handler is called. That handler + should accept two inputs: (CommandLineInterface, MouseEvent) and it + should either handle the event or return `NotImplemented` in case we + want the containing Window to handle this event. + + :param get_tokens: Callable that takes a `CommandLineInterface` instance + and returns the list of (Token, text) tuples to be displayed right now. + :param default_char: default :class:`.Char` (character and Token) to use + for the background when there is more space available than `get_tokens` + returns. + :param get_default_char: Like `default_char`, but this is a callable that + takes a :class:`libs.prompt_toolkit.interface.CommandLineInterface` and + returns a :class:`.Char` instance. + :param has_focus: `bool` or `CLIFilter`, when this evaluates to `True`, + this UI control will take the focus. The cursor will be shown in the + upper left corner of this control, unless `get_token` returns a + ``Token.SetCursorPosition`` token somewhere in the token list, then the + cursor will be shown there. + """ + def __init__(self, get_tokens, default_char=None, get_default_char=None, + align_right=False, align_center=False, has_focus=False): + assert callable(get_tokens) + assert default_char is None or isinstance(default_char, Char) + assert get_default_char is None or callable(get_default_char) + assert not (default_char and get_default_char) + + self.align_right = to_cli_filter(align_right) + self.align_center = to_cli_filter(align_center) + self._has_focus_filter = to_cli_filter(has_focus) + + self.get_tokens = get_tokens + + # Construct `get_default_char` callable. + if default_char: + get_default_char = lambda _: default_char + elif not get_default_char: + get_default_char = lambda _: Char(' ', Token.Transparent) + + self.get_default_char = get_default_char + + #: Cache for the content. + self._content_cache = SimpleCache(maxsize=18) + self._token_cache = SimpleCache(maxsize=1) + # Only cache one token list. We don't need the previous item. + + # Render info for the mouse support. + self._tokens = None + + def reset(self): + self._tokens = None + + def __repr__(self): + return '%s(%r)' % (self.__class__.__name__, self.get_tokens) + + def _get_tokens_cached(self, cli): + """ + Get tokens, but only retrieve tokens once during one render run. + (This function is called several times during one rendering, because + we also need those for calculating the dimensions.) + """ + return self._token_cache.get( + cli.render_counter, lambda: self.get_tokens(cli)) + + def has_focus(self, cli): + return self._has_focus_filter(cli) + + def preferred_width(self, cli, max_available_width): + """ + Return the preferred width for this control. + That is the width of the longest line. + """ + text = token_list_to_text(self._get_tokens_cached(cli)) + line_lengths = [get_cwidth(l) for l in text.split('\n')] + return max(line_lengths) + + def preferred_height(self, cli, width, max_available_height, wrap_lines): + content = self.create_content(cli, width, None) + return content.line_count + + def create_content(self, cli, width, height): + # Get tokens + tokens_with_mouse_handlers = self._get_tokens_cached(cli) + + default_char = self.get_default_char(cli) + + # Wrap/align right/center parameters. + right = self.align_right(cli) + center = self.align_center(cli) + + def process_line(line): + " Center or right align a single line. " + used_width = token_list_width(line) + padding = width - used_width + if center: + padding = int(padding / 2) + return [(default_char.token, default_char.char * padding)] + line + + if right or center: + token_lines_with_mouse_handlers = [] + + for line in split_lines(tokens_with_mouse_handlers): + token_lines_with_mouse_handlers.append(process_line(line)) + else: + token_lines_with_mouse_handlers = list(split_lines(tokens_with_mouse_handlers)) + + # Strip mouse handlers from tokens. + token_lines = [ + [tuple(item[:2]) for item in line] + for line in token_lines_with_mouse_handlers + ] + + # Keep track of the tokens with mouse handler, for later use in + # `mouse_handler`. + self._tokens = tokens_with_mouse_handlers + + # If there is a `Token.SetCursorPosition` in the token list, set the + # cursor position here. + def get_cursor_position(): + SetCursorPosition = Token.SetCursorPosition + + for y, line in enumerate(token_lines): + x = 0 + for token, text in line: + if token == SetCursorPosition: + return Point(x=x, y=y) + x += len(text) + return None + + # Create content, or take it from the cache. + key = (default_char.char, default_char.token, + tuple(tokens_with_mouse_handlers), width, right, center) + + def get_content(): + return UIContent(get_line=lambda i: token_lines[i], + line_count=len(token_lines), + default_char=default_char, + cursor_position=get_cursor_position()) + + return self._content_cache.get(key, get_content) + + @classmethod + def static(cls, tokens): + def get_static_tokens(cli): + return tokens + return cls(get_static_tokens) + + def mouse_handler(self, cli, mouse_event): + """ + Handle mouse events. + + (When the token list contained mouse handlers and the user clicked on + on any of these, the matching handler is called. This handler can still + return `NotImplemented` in case we want the `Window` to handle this + particular event.) + """ + if self._tokens: + # Read the generator. + tokens_for_line = list(split_lines(self._tokens)) + + try: + tokens = tokens_for_line[mouse_event.position.y] + except IndexError: + return NotImplemented + else: + # Find position in the token list. + xpos = mouse_event.position.x + + # Find mouse handler for this character. + count = 0 + for item in tokens: + count += len(item[1]) + if count >= xpos: + if len(item) >= 3: + # Handler found. Call it. + # (Handler can return NotImplemented, so return + # that result.) + handler = item[2] + return handler(cli, mouse_event) + else: + break + + # Otherwise, don't handle here. + return NotImplemented + + +class FillControl(UIControl): + """ + Fill whole control with characters with this token. + (Also helpful for debugging.) + + :param char: :class:`.Char` instance to use for filling. + :param get_char: A callable that takes a CommandLineInterface and returns a + :class:`.Char` object. + """ + def __init__(self, character=None, token=Token, char=None, get_char=None): # 'character' and 'token' parameters are deprecated. + assert char is None or isinstance(char, Char) + assert get_char is None or callable(get_char) + assert not (char and get_char) + + self.char = char + + if character: + # Passing (character=' ', token=token) is deprecated. + self.character = character + self.token = token + + self.get_char = lambda cli: Char(character, token) + elif get_char: + # When 'get_char' is given. + self.get_char = get_char + else: + # When 'char' is given. + self.char = self.char or Char() + self.get_char = lambda cli: self.char + self.char = char + + def __repr__(self): + if self.char: + return '%s(char=%r)' % (self.__class__.__name__, self.char) + else: + return '%s(get_char=%r)' % (self.__class__.__name__, self.get_char) + + def reset(self): + pass + + def has_focus(self, cli): + return False + + def create_content(self, cli, width, height): + def get_line(i): + return [] + + return UIContent( + get_line=get_line, + line_count=100 ** 100, # Something very big. + default_char=self.get_char(cli)) + + +_ProcessedLine = namedtuple('_ProcessedLine', 'tokens source_to_display display_to_source') + + +class BufferControl(UIControl): + """ + Control for visualising the content of a `Buffer`. + + :param input_processors: list of :class:`~libs.prompt_toolkit.layout.processors.Processor`. + :param lexer: :class:`~libs.prompt_toolkit.layout.lexers.Lexer` instance for syntax highlighting. + :param preview_search: `bool` or `CLIFilter`: Show search while typing. + :param get_search_state: Callable that takes a CommandLineInterface and + returns the SearchState to be used. (If not CommandLineInterface.search_state.) + :param buffer_name: String representing the name of the buffer to display. + :param default_char: :class:`.Char` instance to use to fill the background. This is + transparent by default. + :param focus_on_click: Focus this buffer when it's click, but not yet focussed. + """ + def __init__(self, + buffer_name=DEFAULT_BUFFER, + input_processors=None, + lexer=None, + preview_search=False, + search_buffer_name=SEARCH_BUFFER, + get_search_state=None, + menu_position=None, + default_char=None, + focus_on_click=False): + assert input_processors is None or all(isinstance(i, Processor) for i in input_processors) + assert menu_position is None or callable(menu_position) + assert lexer is None or isinstance(lexer, Lexer) + assert get_search_state is None or callable(get_search_state) + assert default_char is None or isinstance(default_char, Char) + + self.preview_search = to_cli_filter(preview_search) + self.get_search_state = get_search_state + self.focus_on_click = to_cli_filter(focus_on_click) + + self.input_processors = input_processors or [] + self.buffer_name = buffer_name + self.menu_position = menu_position + self.lexer = lexer or SimpleLexer() + self.default_char = default_char or Char(token=Token.Transparent) + self.search_buffer_name = search_buffer_name + + #: Cache for the lexer. + #: Often, due to cursor movement, undo/redo and window resizing + #: operations, it happens that a short time, the same document has to be + #: lexed. This is a faily easy way to cache such an expensive operation. + self._token_cache = SimpleCache(maxsize=8) + + self._xy_to_cursor_position = None + self._last_click_timestamp = None + self._last_get_processed_line = None + + def _buffer(self, cli): + """ + The buffer object that contains the 'main' content. + """ + return cli.buffers[self.buffer_name] + + def has_focus(self, cli): + # This control gets the focussed if the actual `Buffer` instance has the + # focus or when any of the `InputProcessor` classes tells us that it + # wants the focus. (E.g. in case of a reverse-search, where the actual + # search buffer may not be displayed, but the "reverse-i-search" text + # should get the focus.) + return cli.current_buffer_name == self.buffer_name or \ + any(i.has_focus(cli) for i in self.input_processors) + + def preferred_width(self, cli, max_available_width): + """ + This should return the preferred width. + + Note: We don't specify a preferred width according to the content, + because it would be too expensive. Calculating the preferred + width can be done by calculating the longest line, but this would + require applying all the processors to each line. This is + unfeasible for a larger document, and doing it for small + documents only would result in inconsistent behaviour. + """ + return None + + def preferred_height(self, cli, width, max_available_height, wrap_lines): + # Calculate the content height, if it was drawn on a screen with the + # given width. + height = 0 + content = self.create_content(cli, width, None) + + # When line wrapping is off, the height should be equal to the amount + # of lines. + if not wrap_lines: + return content.line_count + + # When the number of lines exceeds the max_available_height, just + # return max_available_height. No need to calculate anything. + if content.line_count >= max_available_height: + return max_available_height + + for i in range(content.line_count): + height += content.get_height_for_line(i, width) + + if height >= max_available_height: + return max_available_height + + return height + + def _get_tokens_for_line_func(self, cli, document): + """ + Create a function that returns the tokens for a given line. + """ + # Cache using `document.text`. + def get_tokens_for_line(): + return self.lexer.lex_document(cli, document) + + return self._token_cache.get(document.text, get_tokens_for_line) + + def _create_get_processed_line_func(self, cli, document): + """ + Create a function that takes a line number of the current document and + returns a _ProcessedLine(processed_tokens, source_to_display, display_to_source) + tuple. + """ + def transform(lineno, tokens): + " Transform the tokens for a given line number. " + source_to_display_functions = [] + display_to_source_functions = [] + + # Get cursor position at this line. + if document.cursor_position_row == lineno: + cursor_column = document.cursor_position_col + else: + cursor_column = None + + def source_to_display(i): + """ Translate x position from the buffer to the x position in the + processed token list. """ + for f in source_to_display_functions: + i = f(i) + return i + + # Apply each processor. + for p in self.input_processors: + transformation = p.apply_transformation( + cli, document, lineno, source_to_display, tokens) + tokens = transformation.tokens + + if cursor_column: + cursor_column = transformation.source_to_display(cursor_column) + + display_to_source_functions.append(transformation.display_to_source) + source_to_display_functions.append(transformation.source_to_display) + + def display_to_source(i): + for f in reversed(display_to_source_functions): + i = f(i) + return i + + return _ProcessedLine(tokens, source_to_display, display_to_source) + + def create_func(): + get_line = self._get_tokens_for_line_func(cli, document) + cache = {} + + def get_processed_line(i): + try: + return cache[i] + except KeyError: + processed_line = transform(i, get_line(i)) + cache[i] = processed_line + return processed_line + return get_processed_line + + return create_func() + + def create_content(self, cli, width, height): + """ + Create a UIContent. + """ + buffer = self._buffer(cli) + + # Get the document to be shown. If we are currently searching (the + # search buffer has focus, and the preview_search filter is enabled), + # then use the search document, which has possibly a different + # text/cursor position.) + def preview_now(): + """ True when we should preview a search. """ + return bool(self.preview_search(cli) and + cli.buffers[self.search_buffer_name].text) + + if preview_now(): + if self.get_search_state: + ss = self.get_search_state(cli) + else: + ss = cli.search_state + + document = buffer.document_for_search(SearchState( + text=cli.current_buffer.text, + direction=ss.direction, + ignore_case=ss.ignore_case)) + else: + document = buffer.document + + get_processed_line = self._create_get_processed_line_func(cli, document) + self._last_get_processed_line = get_processed_line + + def translate_rowcol(row, col): + " Return the content column for this coordinate. " + return Point(y=row, x=get_processed_line(row).source_to_display(col)) + + def get_line(i): + " Return the tokens for a given line number. " + tokens = get_processed_line(i).tokens + + # Add a space at the end, because that is a possible cursor + # position. (When inserting after the input.) We should do this on + # all the lines, not just the line containing the cursor. (Because + # otherwise, line wrapping/scrolling could change when moving the + # cursor around.) + tokens = tokens + [(self.default_char.token, ' ')] + return tokens + + content = UIContent( + get_line=get_line, + line_count=document.line_count, + cursor_position=translate_rowcol(document.cursor_position_row, + document.cursor_position_col), + default_char=self.default_char) + + # If there is an auto completion going on, use that start point for a + # pop-up menu position. (But only when this buffer has the focus -- + # there is only one place for a menu, determined by the focussed buffer.) + if cli.current_buffer_name == self.buffer_name: + menu_position = self.menu_position(cli) if self.menu_position else None + if menu_position is not None: + assert isinstance(menu_position, int) + menu_row, menu_col = buffer.document.translate_index_to_position(menu_position) + content.menu_position = translate_rowcol(menu_row, menu_col) + elif buffer.complete_state: + # Position for completion menu. + # Note: We use 'min', because the original cursor position could be + # behind the input string when the actual completion is for + # some reason shorter than the text we had before. (A completion + # can change and shorten the input.) + menu_row, menu_col = buffer.document.translate_index_to_position( + min(buffer.cursor_position, + buffer.complete_state.original_document.cursor_position)) + content.menu_position = translate_rowcol(menu_row, menu_col) + else: + content.menu_position = None + + return content + + def mouse_handler(self, cli, mouse_event): + """ + Mouse handler for this control. + """ + buffer = self._buffer(cli) + position = mouse_event.position + + # Focus buffer when clicked. + if self.has_focus(cli): + if self._last_get_processed_line: + processed_line = self._last_get_processed_line(position.y) + + # Translate coordinates back to the cursor position of the + # original input. + xpos = processed_line.display_to_source(position.x) + index = buffer.document.translate_row_col_to_index(position.y, xpos) + + # Set the cursor position. + if mouse_event.event_type == MouseEventType.MOUSE_DOWN: + buffer.exit_selection() + buffer.cursor_position = index + + elif mouse_event.event_type == MouseEventType.MOUSE_UP: + # When the cursor was moved to another place, select the text. + # (The >1 is actually a small but acceptable workaround for + # selecting text in Vi navigation mode. In navigation mode, + # the cursor can never be after the text, so the cursor + # will be repositioned automatically.) + if abs(buffer.cursor_position - index) > 1: + buffer.start_selection(selection_type=SelectionType.CHARACTERS) + buffer.cursor_position = index + + # Select word around cursor on double click. + # Two MOUSE_UP events in a short timespan are considered a double click. + double_click = self._last_click_timestamp and time.time() - self._last_click_timestamp < .3 + self._last_click_timestamp = time.time() + + if double_click: + start, end = buffer.document.find_boundaries_of_current_word() + buffer.cursor_position += start + buffer.start_selection(selection_type=SelectionType.CHARACTERS) + buffer.cursor_position += end - start + else: + # Don't handle scroll events here. + return NotImplemented + + # Not focussed, but focussing on click events. + else: + if self.focus_on_click(cli) and mouse_event.event_type == MouseEventType.MOUSE_UP: + # Focus happens on mouseup. (If we did this on mousedown, the + # up event will be received at the point where this widget is + # focussed and be handled anyway.) + cli.focus(self.buffer_name) + else: + return NotImplemented + + def move_cursor_down(self, cli): + b = self._buffer(cli) + b.cursor_position += b.document.get_cursor_down_position() + + def move_cursor_up(self, cli): + b = self._buffer(cli) + b.cursor_position += b.document.get_cursor_up_position() diff --git a/src/libs/prompt_toolkit/layout/dimension.py b/src/libs/prompt_toolkit/layout/dimension.py new file mode 100644 index 0000000..717ad7a --- /dev/null +++ b/src/libs/prompt_toolkit/layout/dimension.py @@ -0,0 +1,92 @@ +""" +Layout dimensions are used to give the minimum, maximum and preferred +dimensions for containers and controls. +""" +from __future__ import unicode_literals + +__all__ = ( + 'LayoutDimension', + 'sum_layout_dimensions', + 'max_layout_dimensions', +) + + +class LayoutDimension(object): + """ + Specified dimension (width/height) of a user control or window. + + The layout engine tries to honor the preferred size. If that is not + possible, because the terminal is larger or smaller, it tries to keep in + between min and max. + + :param min: Minimum size. + :param max: Maximum size. + :param weight: For a VSplit/HSplit, the actual size will be determined + by taking the proportion of weights from all the children. + E.g. When there are two children, one width a weight of 1, + and the other with a weight of 2. The second will always be + twice as big as the first, if the min/max values allow it. + :param preferred: Preferred size. + """ + def __init__(self, min=None, max=None, weight=1, preferred=None): + assert isinstance(weight, int) and weight > 0 # Cannot be a float. + + self.min_specified = min is not None + self.max_specified = max is not None + self.preferred_specified = preferred is not None + + if min is None: + min = 0 # Smallest possible value. + if max is None: # 0-values are allowed, so use "is None" + max = 1000 ** 10 # Something huge. + if preferred is None: + preferred = min + + self.min = min + self.max = max + self.preferred = preferred + self.weight = weight + + # Make sure that the 'preferred' size is always in the min..max range. + if self.preferred < self.min: + self.preferred = self.min + + if self.preferred > self.max: + self.preferred = self.max + + @classmethod + def exact(cls, amount): + """ + Return a :class:`.LayoutDimension` with an exact size. (min, max and + preferred set to ``amount``). + """ + return cls(min=amount, max=amount, preferred=amount) + + def __repr__(self): + return 'LayoutDimension(min=%r, max=%r, preferred=%r, weight=%r)' % ( + self.min, self.max, self.preferred, self.weight) + + def __add__(self, other): + return sum_layout_dimensions([self, other]) + + +def sum_layout_dimensions(dimensions): + """ + Sum a list of :class:`.LayoutDimension` instances. + """ + min = sum([d.min for d in dimensions if d.min is not None]) + max = sum([d.max for d in dimensions if d.max is not None]) + preferred = sum([d.preferred for d in dimensions]) + + return LayoutDimension(min=min, max=max, preferred=preferred) + + +def max_layout_dimensions(dimensions): + """ + Take the maximum of a list of :class:`.LayoutDimension` instances. + """ + min_ = max([d.min for d in dimensions if d.min is not None]) + max_ = max([d.max for d in dimensions if d.max is not None]) + preferred = max([d.preferred for d in dimensions]) + + return LayoutDimension(min=min_, max=max_, preferred=preferred) diff --git a/src/libs/prompt_toolkit/layout/lexers.py b/src/libs/prompt_toolkit/layout/lexers.py new file mode 100644 index 0000000..b50c3f6 --- /dev/null +++ b/src/libs/prompt_toolkit/layout/lexers.py @@ -0,0 +1,320 @@ +""" +Lexer interface and implementation. +Used for syntax highlighting. +""" +from __future__ import unicode_literals +from abc import ABCMeta, abstractmethod +from six import with_metaclass +from six.moves import range + +from libs.prompt_toolkit.token import Token +from libs.prompt_toolkit.filters import to_cli_filter +from .utils import split_lines + +import re +import six + +__all__ = ( + 'Lexer', + 'SimpleLexer', + 'PygmentsLexer', + 'SyntaxSync', + 'SyncFromStart', + 'RegexSync', +) + + +class Lexer(with_metaclass(ABCMeta, object)): + """ + Base class for all lexers. + """ + @abstractmethod + def lex_document(self, cli, document): + """ + Takes a :class:`~libs.prompt_toolkit.document.Document` and returns a + callable that takes a line number and returns the tokens for that line. + """ + + +class SimpleLexer(Lexer): + """ + Lexer that doesn't do any tokenizing and returns the whole input as one token. + + :param token: The `Token` for this lexer. + """ + # `default_token` parameter is deprecated! + def __init__(self, token=Token, default_token=None): + self.token = token + + if default_token is not None: + self.token = default_token + + def lex_document(self, cli, document): + lines = document.lines + + def get_line(lineno): + " Return the tokens for the given line. " + try: + return [(self.token, lines[lineno])] + except IndexError: + return [] + return get_line + + +class SyntaxSync(with_metaclass(ABCMeta, object)): + """ + Syntax synchroniser. This is a tool that finds a start position for the + lexer. This is especially important when editing big documents; we don't + want to start the highlighting by running the lexer from the beginning of + the file. That is very slow when editing. + """ + @abstractmethod + def get_sync_start_position(self, document, lineno): + """ + Return the position from where we can start lexing as a (row, column) + tuple. + + :param document: `Document` instance that contains all the lines. + :param lineno: The line that we want to highlight. (We need to return + this line, or an earlier position.) + """ + +class SyncFromStart(SyntaxSync): + """ + Always start the syntax highlighting from the beginning. + """ + def get_sync_start_position(self, document, lineno): + return 0, 0 + + +class RegexSync(SyntaxSync): + """ + Synchronize by starting at a line that matches the given regex pattern. + """ + # Never go more than this amount of lines backwards for synchronisation. + # That would be too CPU intensive. + MAX_BACKWARDS = 500 + + # Start lexing at the start, if we are in the first 'n' lines and no + # synchronisation position was found. + FROM_START_IF_NO_SYNC_POS_FOUND = 100 + + def __init__(self, pattern): + assert isinstance(pattern, six.text_type) + self._compiled_pattern = re.compile(pattern) + + def get_sync_start_position(self, document, lineno): + " Scan backwards, and find a possible position to start. " + pattern = self._compiled_pattern + lines = document.lines + + # Scan upwards, until we find a point where we can start the syntax + # synchronisation. + for i in range(lineno, max(-1, lineno - self.MAX_BACKWARDS), -1): + match = pattern.match(lines[i]) + if match: + return i, match.start() + + # No synchronisation point found. If we aren't that far from the + # beginning, start at the very beginning, otherwise, just try to start + # at the current line. + if lineno < self.FROM_START_IF_NO_SYNC_POS_FOUND: + return 0, 0 + else: + return lineno, 0 + + @classmethod + def from_pygments_lexer_cls(cls, lexer_cls): + """ + Create a :class:`.RegexSync` instance for this Pygments lexer class. + """ + patterns = { + # For Python, start highlighting at any class/def block. + 'Python': r'^\s*(class|def)\s+', + 'Python 3': r'^\s*(class|def)\s+', + + # For HTML, start at any open/close tag definition. + 'HTML': r'<[/a-zA-Z]', + + # For javascript, start at a function. + 'JavaScript': r'\bfunction\b' + + # TODO: Add definitions for other languages. + # By default, we start at every possible line. + } + p = patterns.get(lexer_cls.name, '^') + return cls(p) + + +class PygmentsLexer(Lexer): + """ + Lexer that calls a pygments lexer. + + Example:: + + from pygments.lexers import HtmlLexer + lexer = PygmentsLexer(HtmlLexer) + + Note: Don't forget to also load a Pygments compatible style. E.g.:: + + from libs.prompt_toolkit.styles.from_pygments import style_from_pygments + from pygments.styles import get_style_by_name + style = style_from_pygments(get_style_by_name('monokai')) + + :param pygments_lexer_cls: A `Lexer` from Pygments. + :param sync_from_start: Start lexing at the start of the document. This + will always give the best results, but it will be slow for bigger + documents. (When the last part of the document is display, then the + whole document will be lexed by Pygments on every key stroke.) It is + recommended to disable this for inputs that are expected to be more + than 1,000 lines. + :param syntax_sync: `SyntaxSync` object. + """ + # Minimum amount of lines to go backwards when starting the parser. + # This is important when the lines are retrieved in reverse order, or when + # scrolling upwards. (Due to the complexity of calculating the vertical + # scroll offset in the `Window` class, lines are not always retrieved in + # order.) + MIN_LINES_BACKWARDS = 50 + + # When a parser was started this amount of lines back, read the parser + # until we get the current line. Otherwise, start a new parser. + # (This should probably be bigger than MIN_LINES_BACKWARDS.) + REUSE_GENERATOR_MAX_DISTANCE = 100 + + def __init__(self, pygments_lexer_cls, sync_from_start=True, syntax_sync=None): + assert syntax_sync is None or isinstance(syntax_sync, SyntaxSync) + + self.pygments_lexer_cls = pygments_lexer_cls + self.sync_from_start = to_cli_filter(sync_from_start) + + # Instantiate the Pygments lexer. + self.pygments_lexer = pygments_lexer_cls( + stripnl=False, + stripall=False, + ensurenl=False) + + # Create syntax sync instance. + self.syntax_sync = syntax_sync or RegexSync.from_pygments_lexer_cls(pygments_lexer_cls) + + @classmethod + def from_filename(cls, filename, sync_from_start=True): + """ + Create a `Lexer` from a filename. + """ + # Inline imports: the Pygments dependency is optional! + from pygments.util import ClassNotFound + from pygments.lexers import get_lexer_for_filename + + try: + pygments_lexer = get_lexer_for_filename(filename) + except ClassNotFound: + return SimpleLexer() + else: + return cls(pygments_lexer.__class__, sync_from_start=sync_from_start) + + def lex_document(self, cli, document): + """ + Create a lexer function that takes a line number and returns the list + of (Token, text) tuples as the Pygments lexer returns for that line. + """ + # Cache of already lexed lines. + cache = {} + + # Pygments generators that are currently lexing. + line_generators = {} # Map lexer generator to the line number. + + def get_syntax_sync(): + " The Syntax synchronisation objcet that we currently use. " + if self.sync_from_start(cli): + return SyncFromStart() + else: + return self.syntax_sync + + def find_closest_generator(i): + " Return a generator close to line 'i', or None if none was fonud. " + for generator, lineno in line_generators.items(): + if lineno < i and i - lineno < self.REUSE_GENERATOR_MAX_DISTANCE: + return generator + + def create_line_generator(start_lineno, column=0): + """ + Create a generator that yields the lexed lines. + Each iteration it yields a (line_number, [(token, text), ...]) tuple. + """ + def get_tokens(): + text = '\n'.join(document.lines[start_lineno:])[column:] + + # We call `get_tokens_unprocessed`, because `get_tokens` will + # still replace \r\n and \r by \n. (We don't want that, + # Pygments should return exactly the same amount of text, as we + # have given as input.) + for _, t, v in self.pygments_lexer.get_tokens_unprocessed(text): + yield t, v + + return enumerate(split_lines(get_tokens()), start_lineno) + + def get_generator(i): + """ + Find an already started generator that is close, or create a new one. + """ + # Find closest line generator. + generator = find_closest_generator(i) + if generator: + return generator + + # No generator found. Determine starting point for the syntax + # synchronisation first. + + # Go at least x lines back. (Make scrolling upwards more + # efficient.) + i = max(0, i - self.MIN_LINES_BACKWARDS) + + if i == 0: + row = 0 + column = 0 + else: + row, column = get_syntax_sync().get_sync_start_position(document, i) + + # Find generator close to this point, or otherwise create a new one. + generator = find_closest_generator(i) + if generator: + return generator + else: + generator = create_line_generator(row, column) + + # If the column is not 0, ignore the first line. (Which is + # incomplete. This happens when the synchronisation algorithm tells + # us to start parsing in the middle of a line.) + if column: + next(generator) + row += 1 + + line_generators[generator] = row + return generator + + def get_line(i): + " Return the tokens for a given line number. " + try: + return cache[i] + except KeyError: + generator = get_generator(i) + + # Exhaust the generator, until we find the requested line. + for num, line in generator: + cache[num] = line + if num == i: + line_generators[generator] = i + + # Remove the next item from the cache. + # (It could happen that it's already there, because of + # another generator that started filling these lines, + # but we want to synchronise these lines with the + # current lexer's state.) + if num + 1 in cache: + del cache[num + 1] + + return cache[num] + return [] + + return get_line diff --git a/src/libs/prompt_toolkit/layout/margins.py b/src/libs/prompt_toolkit/layout/margins.py new file mode 100644 index 0000000..9a25834 --- /dev/null +++ b/src/libs/prompt_toolkit/layout/margins.py @@ -0,0 +1,253 @@ +""" +Margin implementations for a :class:`~libs.prompt_toolkit.layout.containers.Window`. +""" +from __future__ import unicode_literals + +from abc import ABCMeta, abstractmethod +from six import with_metaclass +from six.moves import range + +from libs.prompt_toolkit.filters import to_cli_filter +from libs.prompt_toolkit.token import Token +from libs.prompt_toolkit.utils import get_cwidth +from .utils import token_list_to_text + +__all__ = ( + 'Margin', + 'NumberredMargin', + 'ScrollbarMargin', + 'ConditionalMargin', + 'PromptMargin', +) + + +class Margin(with_metaclass(ABCMeta, object)): + """ + Base interface for a margin. + """ + @abstractmethod + def get_width(self, cli, get_ui_content): + """ + Return the width that this margin is going to consume. + + :param cli: :class:`.CommandLineInterface` instance. + :param get_ui_content: Callable that asks the user control to create + a :class:`.UIContent` instance. This can be used for instance to + obtain the number of lines. + """ + return 0 + + @abstractmethod + def create_margin(self, cli, window_render_info, width, height): + """ + Creates a margin. + This should return a list of (Token, text) tuples. + + :param cli: :class:`.CommandLineInterface` instance. + :param window_render_info: + :class:`~libs.prompt_toolkit.layout.containers.WindowRenderInfo` + instance, generated after rendering and copying the visible part of + the :class:`~libs.prompt_toolkit.layout.controls.UIControl` into the + :class:`~libs.prompt_toolkit.layout.containers.Window`. + :param width: The width that's available for this margin. (As reported + by :meth:`.get_width`.) + :param height: The height that's available for this margin. (The height + of the :class:`~libs.prompt_toolkit.layout.containers.Window`.) + """ + return [] + + +class NumberredMargin(Margin): + """ + Margin that displays the line numbers. + + :param relative: Number relative to the cursor position. Similar to the Vi + 'relativenumber' option. + :param display_tildes: Display tildes after the end of the document, just + like Vi does. + """ + def __init__(self, relative=False, display_tildes=False): + self.relative = to_cli_filter(relative) + self.display_tildes = to_cli_filter(display_tildes) + + def get_width(self, cli, get_ui_content): + line_count = get_ui_content().line_count + return max(3, len('%s' % line_count) + 1) + + def create_margin(self, cli, window_render_info, width, height): + relative = self.relative(cli) + + token = Token.LineNumber + token_current = Token.LineNumber.Current + + # Get current line number. + current_lineno = window_render_info.ui_content.cursor_position.y + + # Construct margin. + result = [] + last_lineno = None + + for y, lineno in enumerate(window_render_info.displayed_lines): + # Only display line number if this line is not a continuation of the previous line. + if lineno != last_lineno: + if lineno is None: + pass + elif lineno == current_lineno: + # Current line. + if relative: + # Left align current number in relative mode. + result.append((token_current, '%i' % (lineno + 1))) + else: + result.append((token_current, ('%i ' % (lineno + 1)).rjust(width))) + else: + # Other lines. + if relative: + lineno = abs(lineno - current_lineno) - 1 + + result.append((token, ('%i ' % (lineno + 1)).rjust(width))) + + last_lineno = lineno + result.append((Token, '\n')) + + # Fill with tildes. + if self.display_tildes(cli): + while y < window_render_info.window_height: + result.append((Token.Tilde, '~\n')) + y += 1 + + return result + + +class ConditionalMargin(Margin): + """ + Wrapper around other :class:`.Margin` classes to show/hide them. + """ + def __init__(self, margin, filter): + assert isinstance(margin, Margin) + + self.margin = margin + self.filter = to_cli_filter(filter) + + def get_width(self, cli, ui_content): + if self.filter(cli): + return self.margin.get_width(cli, ui_content) + else: + return 0 + + def create_margin(self, cli, window_render_info, width, height): + if width and self.filter(cli): + return self.margin.create_margin(cli, window_render_info, width, height) + else: + return [] + + +class ScrollbarMargin(Margin): + """ + Margin displaying a scrollbar. + + :param display_arrows: Display scroll up/down arrows. + """ + def __init__(self, display_arrows=False): + self.display_arrows = to_cli_filter(display_arrows) + + def get_width(self, cli, ui_content): + return 1 + + def create_margin(self, cli, window_render_info, width, height): + total_height = window_render_info.content_height + display_arrows = self.display_arrows(cli) + + window_height = window_render_info.window_height + if display_arrows: + window_height -= 2 + + try: + items_per_row = float(total_height) / min(total_height, window_height) + except ZeroDivisionError: + return [] + else: + def is_scroll_button(row): + " True if we should display a button on this row. " + current_row_middle = int((row + .5) * items_per_row) + return current_row_middle in window_render_info.displayed_lines + + # Up arrow. + result = [] + if display_arrows: + result.extend([ + (Token.Scrollbar.Arrow, '^'), + (Token.Scrollbar, '\n') + ]) + + # Scrollbar body. + for i in range(window_height): + if is_scroll_button(i): + result.append((Token.Scrollbar.Button, ' ')) + else: + result.append((Token.Scrollbar, ' ')) + result.append((Token, '\n')) + + # Down arrow + if display_arrows: + result.append((Token.Scrollbar.Arrow, 'v')) + + return result + + +class PromptMargin(Margin): + """ + Create margin that displays a prompt. + This can display one prompt at the first line, and a continuation prompt + (e.g, just dots) on all the following lines. + + :param get_prompt_tokens: Callable that takes a CommandLineInterface as + input and returns a list of (Token, type) tuples to be shown as the + prompt at the first line. + :param get_continuation_tokens: Callable that takes a CommandLineInterface + and a width as input and returns a list of (Token, type) tuples for the + next lines of the input. + :param show_numbers: (bool or :class:`~libs.prompt_toolkit.filters.CLIFilter`) + Display line numbers instead of the continuation prompt. + """ + def __init__(self, get_prompt_tokens, get_continuation_tokens=None, + show_numbers=False): + assert callable(get_prompt_tokens) + assert get_continuation_tokens is None or callable(get_continuation_tokens) + show_numbers = to_cli_filter(show_numbers) + + self.get_prompt_tokens = get_prompt_tokens + self.get_continuation_tokens = get_continuation_tokens + self.show_numbers = show_numbers + + def get_width(self, cli, ui_content): + " Width to report to the `Window`. " + # Take the width from the first line. + text = token_list_to_text(self.get_prompt_tokens(cli)) + return get_cwidth(text) + + def create_margin(self, cli, window_render_info, width, height): + # First line. + tokens = self.get_prompt_tokens(cli)[:] + + # Next lines. (Show line numbering when numbering is enabled.) + if self.get_continuation_tokens: + # Note: we turn this into a list, to make sure that we fail early + # in case `get_continuation_tokens` returns something else, + # like `None`. + tokens2 = list(self.get_continuation_tokens(cli, width)) + else: + tokens2 = [] + + show_numbers = self.show_numbers(cli) + last_y = None + + for y in window_render_info.displayed_lines[1:]: + tokens.append((Token, '\n')) + if show_numbers: + if y != last_y: + tokens.append((Token.LineNumber, ('%i ' % (y + 1)).rjust(width))) + else: + tokens.extend(tokens2) + last_y = y + + return tokens diff --git a/src/libs/prompt_toolkit/layout/menus.py b/src/libs/prompt_toolkit/layout/menus.py new file mode 100644 index 0000000..cd79b8f --- /dev/null +++ b/src/libs/prompt_toolkit/layout/menus.py @@ -0,0 +1,496 @@ +from __future__ import unicode_literals + +from six.moves import zip_longest, range +from libs.prompt_toolkit.filters import HasCompletions, IsDone, Condition, to_cli_filter +from libs.prompt_toolkit.mouse_events import MouseEventType +from libs.prompt_toolkit.token import Token +from libs.prompt_toolkit.utils import get_cwidth + +from .containers import Window, HSplit, ConditionalContainer, ScrollOffsets +from .controls import UIControl, UIContent +from .dimension import LayoutDimension +from .margins import ScrollbarMargin +from .screen import Point, Char + +import math + +__all__ = ( + 'CompletionsMenu', + 'MultiColumnCompletionsMenu', +) + + +class CompletionsMenuControl(UIControl): + """ + Helper for drawing the complete menu to the screen. + + :param scroll_offset: Number (integer) representing the preferred amount of + completions to be displayed before and after the current one. When this + is a very high number, the current completion will be shown in the + middle most of the time. + """ + # Preferred minimum size of the menu control. + # The CompletionsMenu class defines a width of 8, and there is a scrollbar + # of 1.) + MIN_WIDTH = 7 + + def __init__(self): + self.token = Token.Menu.Completions + + def has_focus(self, cli): + return False + + def preferred_width(self, cli, max_available_width): + complete_state = cli.current_buffer.complete_state + if complete_state: + menu_width = self._get_menu_width(500, complete_state) + menu_meta_width = self._get_menu_meta_width(500, complete_state) + + return menu_width + menu_meta_width + else: + return 0 + + def preferred_height(self, cli, width, max_available_height, wrap_lines): + complete_state = cli.current_buffer.complete_state + if complete_state: + return len(complete_state.current_completions) + else: + return 0 + + def create_content(self, cli, width, height): + """ + Create a UIContent object for this control. + """ + complete_state = cli.current_buffer.complete_state + if complete_state: + completions = complete_state.current_completions + index = complete_state.complete_index # Can be None! + + # Calculate width of completions menu. + menu_width = self._get_menu_width(width, complete_state) + menu_meta_width = self._get_menu_meta_width(width - menu_width, complete_state) + show_meta = self._show_meta(complete_state) + + def get_line(i): + c = completions[i] + is_current_completion = (i == index) + result = self._get_menu_item_tokens(c, is_current_completion, menu_width) + + if show_meta: + result += self._get_menu_item_meta_tokens(c, is_current_completion, menu_meta_width) + return result + + return UIContent(get_line=get_line, + cursor_position=Point(x=0, y=index or 0), + line_count=len(completions), + default_char=Char(' ', self.token)) + + return UIContent() + + def _show_meta(self, complete_state): + """ + Return ``True`` if we need to show a column with meta information. + """ + return any(c.display_meta for c in complete_state.current_completions) + + def _get_menu_width(self, max_width, complete_state): + """ + Return the width of the main column. + """ + return min(max_width, max(self.MIN_WIDTH, max(get_cwidth(c.display) + for c in complete_state.current_completions) + 2)) + + def _get_menu_meta_width(self, max_width, complete_state): + """ + Return the width of the meta column. + """ + if self._show_meta(complete_state): + return min(max_width, max(get_cwidth(c.display_meta) + for c in complete_state.current_completions) + 2) + else: + return 0 + + def _get_menu_item_tokens(self, completion, is_current_completion, width): + if is_current_completion: + token = self.token.Completion.Current + else: + token = self.token.Completion + + text, tw = _trim_text(completion.display, width - 2) + padding = ' ' * (width - 2 - tw) + return [(token, ' %s%s ' % (text, padding))] + + def _get_menu_item_meta_tokens(self, completion, is_current_completion, width): + if is_current_completion: + token = self.token.Meta.Current + else: + token = self.token.Meta + + text, tw = _trim_text(completion.display_meta, width - 2) + padding = ' ' * (width - 2 - tw) + return [(token, ' %s%s ' % (text, padding))] + + def mouse_handler(self, cli, mouse_event): + """ + Handle mouse events: clicking and scrolling. + """ + b = cli.current_buffer + + if mouse_event.event_type == MouseEventType.MOUSE_UP: + # Select completion. + b.go_to_completion(mouse_event.position.y) + b.complete_state = None + + elif mouse_event.event_type == MouseEventType.SCROLL_DOWN: + # Scroll up. + b.complete_next(count=3, disable_wrap_around=True) + + elif mouse_event.event_type == MouseEventType.SCROLL_UP: + # Scroll down. + b.complete_previous(count=3, disable_wrap_around=True) + + +def _trim_text(text, max_width): + """ + Trim the text to `max_width`, append dots when the text is too long. + Returns (text, width) tuple. + """ + width = get_cwidth(text) + + # When the text is too wide, trim it. + if width > max_width: + # When there are no double width characters, just use slice operation. + if len(text) == width: + trimmed_text = (text[:max(1, max_width-3)] + '...')[:max_width] + return trimmed_text, len(trimmed_text) + + # Otherwise, loop until we have the desired width. (Rather + # inefficient, but ok for now.) + else: + trimmed_text = '' + for c in text: + if get_cwidth(trimmed_text + c) <= max_width - 3: + trimmed_text += c + trimmed_text += '...' + + return (trimmed_text, get_cwidth(trimmed_text)) + else: + return text, width + + +class CompletionsMenu(ConditionalContainer): + def __init__(self, max_height=None, scroll_offset=0, extra_filter=True, display_arrows=False): + extra_filter = to_cli_filter(extra_filter) + display_arrows = to_cli_filter(display_arrows) + + super(CompletionsMenu, self).__init__( + content=Window( + content=CompletionsMenuControl(), + width=LayoutDimension(min=8), + height=LayoutDimension(min=1, max=max_height), + scroll_offsets=ScrollOffsets(top=scroll_offset, bottom=scroll_offset), + right_margins=[ScrollbarMargin(display_arrows=display_arrows)], + dont_extend_width=True, + ), + # Show when there are completions but not at the point we are + # returning the input. + filter=HasCompletions() & ~IsDone() & extra_filter) + + +class MultiColumnCompletionMenuControl(UIControl): + """ + Completion menu that displays all the completions in several columns. + When there are more completions than space for them to be displayed, an + arrow is shown on the left or right side. + + `min_rows` indicates how many rows will be available in any possible case. + When this is langer than one, in will try to use less columns and more + rows until this value is reached. + Be careful passing in a too big value, if less than the given amount of + rows are available, more columns would have been required, but + `preferred_width` doesn't know about that and reports a too small value. + This results in less completions displayed and additional scrolling. + (It's a limitation of how the layout engine currently works: first the + widths are calculated, then the heights.) + + :param suggested_max_column_width: The suggested max width of a column. + The column can still be bigger than this, but if there is place for two + columns of this width, we will display two columns. This to avoid that + if there is one very wide completion, that it doesn't significantly + reduce the amount of columns. + """ + _required_margin = 3 # One extra padding on the right + space for arrows. + + def __init__(self, min_rows=3, suggested_max_column_width=30): + assert isinstance(min_rows, int) and min_rows >= 1 + + self.min_rows = min_rows + self.suggested_max_column_width = suggested_max_column_width + self.token = Token.Menu.Completions + self.scroll = 0 + + # Info of last rendering. + self._rendered_rows = 0 + self._rendered_columns = 0 + self._total_columns = 0 + self._render_pos_to_completion = {} + self._render_left_arrow = False + self._render_right_arrow = False + self._render_width = 0 + + def reset(self): + self.scroll = 0 + + def has_focus(self, cli): + return False + + def preferred_width(self, cli, max_available_width): + """ + Preferred width: prefer to use at least min_rows, but otherwise as much + as possible horizontally. + """ + complete_state = cli.current_buffer.complete_state + column_width = self._get_column_width(complete_state) + result = int(column_width * math.ceil(len(complete_state.current_completions) / float(self.min_rows))) + + # When the desired width is still more than the maximum available, + # reduce by removing columns until we are less than the available + # width. + while result > column_width and result > max_available_width - self._required_margin: + result -= column_width + return result + self._required_margin + + def preferred_height(self, cli, width, max_available_height, wrap_lines): + """ + Preferred height: as much as needed in order to display all the completions. + """ + complete_state = cli.current_buffer.complete_state + column_width = self._get_column_width(complete_state) + column_count = max(1, (width - self._required_margin) // column_width) + + return int(math.ceil(len(complete_state.current_completions) / float(column_count))) + + def create_content(self, cli, width, height): + """ + Create a UIContent object for this menu. + """ + complete_state = cli.current_buffer.complete_state + column_width = self._get_column_width(complete_state) + self._render_pos_to_completion = {} + + def grouper(n, iterable, fillvalue=None): + " grouper(3, 'ABCDEFG', 'x') --> ABC DEF Gxx " + args = [iter(iterable)] * n + return zip_longest(fillvalue=fillvalue, *args) + + def is_current_completion(completion): + " Returns True when this completion is the currently selected one. " + return complete_state.complete_index is not None and c == complete_state.current_completion + + # Space required outside of the regular columns, for displaying the + # left and right arrow. + HORIZONTAL_MARGIN_REQUIRED = 3 + + if complete_state: + # There should be at least one column, but it cannot be wider than + # the available width. + column_width = min(width - HORIZONTAL_MARGIN_REQUIRED, column_width) + + # However, when the columns tend to be very wide, because there are + # some very wide entries, shrink it anyway. + if column_width > self.suggested_max_column_width: + # `column_width` can still be bigger that `suggested_max_column_width`, + # but if there is place for two columns, we divide by two. + column_width //= (column_width // self.suggested_max_column_width) + + visible_columns = max(1, (width - self._required_margin) // column_width) + + columns_ = list(grouper(height, complete_state.current_completions)) + rows_ = list(zip(*columns_)) + + # Make sure the current completion is always visible: update scroll offset. + selected_column = (complete_state.complete_index or 0) // height + self.scroll = min(selected_column, max(self.scroll, selected_column - visible_columns + 1)) + + render_left_arrow = self.scroll > 0 + render_right_arrow = self.scroll < len(rows_[0]) - visible_columns + + # Write completions to screen. + tokens_for_line = [] + + for row_index, row in enumerate(rows_): + tokens = [] + middle_row = row_index == len(rows_) // 2 + + # Draw left arrow if we have hidden completions on the left. + if render_left_arrow: + tokens += [(Token.Scrollbar, '<' if middle_row else ' ')] + + # Draw row content. + for column_index, c in enumerate(row[self.scroll:][:visible_columns]): + if c is not None: + tokens += self._get_menu_item_tokens(c, is_current_completion(c), column_width) + + # Remember render position for mouse click handler. + for x in range(column_width): + self._render_pos_to_completion[(column_index * column_width + x, row_index)] = c + else: + tokens += [(self.token.Completion, ' ' * column_width)] + + # Draw trailing padding. (_get_menu_item_tokens only returns padding on the left.) + tokens += [(self.token.Completion, ' ')] + + # Draw right arrow if we have hidden completions on the right. + if render_right_arrow: + tokens += [(Token.Scrollbar, '>' if middle_row else ' ')] + + # Newline. + tokens_for_line.append(tokens) + + else: + tokens = [] + + self._rendered_rows = height + self._rendered_columns = visible_columns + self._total_columns = len(columns_) + self._render_left_arrow = render_left_arrow + self._render_right_arrow = render_right_arrow + self._render_width = column_width * visible_columns + render_left_arrow + render_right_arrow + 1 + + def get_line(i): + return tokens_for_line[i] + + return UIContent(get_line=get_line, line_count=len(rows_)) + + def _get_column_width(self, complete_state): + """ + Return the width of each column. + """ + return max(get_cwidth(c.display) for c in complete_state.current_completions) + 1 + + def _get_menu_item_tokens(self, completion, is_current_completion, width): + if is_current_completion: + token = self.token.Completion.Current + else: + token = self.token.Completion + + text, tw = _trim_text(completion.display, width) + padding = ' ' * (width - tw - 1) + + return [(token, ' %s%s' % (text, padding))] + + def mouse_handler(self, cli, mouse_event): + """ + Handle scoll and click events. + """ + b = cli.current_buffer + + def scroll_left(): + b.complete_previous(count=self._rendered_rows, disable_wrap_around=True) + self.scroll = max(0, self.scroll - 1) + + def scroll_right(): + b.complete_next(count=self._rendered_rows, disable_wrap_around=True) + self.scroll = min(self._total_columns - self._rendered_columns, self.scroll + 1) + + if mouse_event.event_type == MouseEventType.SCROLL_DOWN: + scroll_right() + + elif mouse_event.event_type == MouseEventType.SCROLL_UP: + scroll_left() + + elif mouse_event.event_type == MouseEventType.MOUSE_UP: + x = mouse_event.position.x + y = mouse_event.position.y + + # Mouse click on left arrow. + if x == 0: + if self._render_left_arrow: + scroll_left() + + # Mouse click on right arrow. + elif x == self._render_width - 1: + if self._render_right_arrow: + scroll_right() + + # Mouse click on completion. + else: + completion = self._render_pos_to_completion.get((x, y)) + if completion: + b.apply_completion(completion) + + +class MultiColumnCompletionsMenu(HSplit): + """ + Container that displays the completions in several columns. + When `show_meta` (a :class:`~libs.prompt_toolkit.filters.CLIFilter`) evaluates + to True, it shows the meta information at the bottom. + """ + def __init__(self, min_rows=3, suggested_max_column_width=30, show_meta=True, extra_filter=True): + show_meta = to_cli_filter(show_meta) + extra_filter = to_cli_filter(extra_filter) + + # Display filter: show when there are completions but not at the point + # we are returning the input. + full_filter = HasCompletions() & ~IsDone() & extra_filter + + any_completion_has_meta = Condition(lambda cli: + any(c.display_meta for c in cli.current_buffer.complete_state.current_completions)) + + # Create child windows. + completions_window = ConditionalContainer( + content=Window( + content=MultiColumnCompletionMenuControl( + min_rows=min_rows, suggested_max_column_width=suggested_max_column_width), + width=LayoutDimension(min=8), + height=LayoutDimension(min=1)), + filter=full_filter) + + meta_window = ConditionalContainer( + content=Window(content=_SelectedCompletionMetaControl()), + filter=show_meta & full_filter & any_completion_has_meta) + + # Initialise split. + super(MultiColumnCompletionsMenu, self).__init__([ + completions_window, + meta_window + ]) + + +class _SelectedCompletionMetaControl(UIControl): + """ + Control that shows the meta information of the selected token. + """ + def preferred_width(self, cli, max_available_width): + """ + Report the width of the longest meta text as the preferred width of this control. + + It could be that we use less width, but this way, we're sure that the + layout doesn't change when we select another completion (E.g. that + completions are suddenly shown in more or fewer columns.) + """ + if cli.current_buffer.complete_state: + state = cli.current_buffer.complete_state + return 2 + max(get_cwidth(c.display_meta) for c in state.current_completions) + else: + return 0 + + def preferred_height(self, cli, width, max_available_height, wrap_lines): + return 1 + + def create_content(self, cli, width, height): + tokens = self._get_tokens(cli) + + def get_line(i): + return tokens + + return UIContent(get_line=get_line, line_count=1 if tokens else 0) + + def _get_tokens(self, cli): + token = Token.Menu.Completions.MultiColumnMeta + state = cli.current_buffer.complete_state + + if state and state.current_completion and state.current_completion.display_meta: + return [(token, ' %s ' % state.current_completion.display_meta)] + + return [] diff --git a/src/libs/prompt_toolkit/layout/mouse_handlers.py b/src/libs/prompt_toolkit/layout/mouse_handlers.py new file mode 100644 index 0000000..d443bf8 --- /dev/null +++ b/src/libs/prompt_toolkit/layout/mouse_handlers.py @@ -0,0 +1,29 @@ +from __future__ import unicode_literals + +from itertools import product +from collections import defaultdict + +__all__ = ( + 'MouseHandlers', +) + + +class MouseHandlers(object): + """ + Two dimentional raster of callbacks for mouse events. + """ + def __init__(self): + def dummy_callback(cli, mouse_event): + """ + :param mouse_event: `MouseEvent` instance. + """ + + # Map (x,y) tuples to handlers. + self.mouse_handlers = defaultdict(lambda: dummy_callback) + + def set_mouse_handler_for_range(self, x_min, x_max, y_min, y_max, handler=None): + """ + Set mouse handler for a region. + """ + for x, y in product(range(x_min, x_max), range(y_min, y_max)): + self.mouse_handlers[x,y] = handler diff --git a/src/libs/prompt_toolkit/layout/processors.py b/src/libs/prompt_toolkit/layout/processors.py new file mode 100644 index 0000000..3eff14c --- /dev/null +++ b/src/libs/prompt_toolkit/layout/processors.py @@ -0,0 +1,605 @@ +""" +Processors are little transformation blocks that transform the token list from +a buffer before the BufferControl will render it to the screen. + +They can insert tokens before or after, or highlight fragments by replacing the +token types. +""" +from __future__ import unicode_literals +from abc import ABCMeta, abstractmethod +from six import with_metaclass +from six.moves import range + +from libs.prompt_toolkit.cache import SimpleCache +from libs.prompt_toolkit.document import Document +from libs.prompt_toolkit.enums import SEARCH_BUFFER +from libs.prompt_toolkit.filters import to_cli_filter, ViInsertMultipleMode +from libs.prompt_toolkit.layout.utils import token_list_to_text +from libs.prompt_toolkit.reactive import Integer +from libs.prompt_toolkit.token import Token + +from .utils import token_list_len, explode_tokens + +import re + +__all__ = ( + 'Processor', + 'Transformation', + + 'HighlightSearchProcessor', + 'HighlightSelectionProcessor', + 'PasswordProcessor', + 'HighlightMatchingBracketProcessor', + 'DisplayMultipleCursors', + 'BeforeInput', + 'AfterInput', + 'AppendAutoSuggestion', + 'ConditionalProcessor', + 'ShowLeadingWhiteSpaceProcessor', + 'ShowTrailingWhiteSpaceProcessor', + 'TabsProcessor', +) + + +class Processor(with_metaclass(ABCMeta, object)): + """ + Manipulate the tokens for a given line in a + :class:`~libs.prompt_toolkit.layout.controls.BufferControl`. + """ + @abstractmethod + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + """ + Apply transformation. Returns a :class:`.Transformation` instance. + + :param cli: :class:`.CommandLineInterface` instance. + :param lineno: The number of the line to which we apply the processor. + :param source_to_display: A function that returns the position in the + `tokens` for any position in the source string. (This takes + previous processors into account.) + :param tokens: List of tokens that we can transform. (Received from the + previous processor.) + """ + return Transformation(tokens) + + def has_focus(self, cli): + """ + Processors can override the focus. + (Used for the reverse-i-search prefix in DefaultPrompt.) + """ + return False + + +class Transformation(object): + """ + Transformation result, as returned by :meth:`.Processor.apply_transformation`. + + Important: Always make sure that the length of `document.text` is equal to + the length of all the text in `tokens`! + + :param tokens: The transformed tokens. To be displayed, or to pass to the + next processor. + :param source_to_display: Cursor position transformation from original string to + transformed string. + :param display_to_source: Cursor position transformed from source string to + original string. + """ + def __init__(self, tokens, source_to_display=None, display_to_source=None): + self.tokens = tokens + self.source_to_display = source_to_display or (lambda i: i) + self.display_to_source = display_to_source or (lambda i: i) + + +class HighlightSearchProcessor(Processor): + """ + Processor that highlights search matches in the document. + Note that this doesn't support multiline search matches yet. + + :param preview_search: A Filter; when active it indicates that we take + the search text in real time while the user is typing, instead of the + last active search state. + """ + def __init__(self, preview_search=False, search_buffer_name=SEARCH_BUFFER, + get_search_state=None): + self.preview_search = to_cli_filter(preview_search) + self.search_buffer_name = search_buffer_name + self.get_search_state = get_search_state or (lambda cli: cli.search_state) + + def _get_search_text(self, cli): + """ + The text we are searching for. + """ + # When the search buffer has focus, take that text. + if self.preview_search(cli) and cli.buffers[self.search_buffer_name].text: + return cli.buffers[self.search_buffer_name].text + # Otherwise, take the text of the last active search. + else: + return self.get_search_state(cli).text + + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + search_text = self._get_search_text(cli) + searchmatch_current_token = (':', ) + Token.SearchMatch.Current + searchmatch_token = (':', ) + Token.SearchMatch + + if search_text and not cli.is_returning: + # For each search match, replace the Token. + line_text = token_list_to_text(tokens) + tokens = explode_tokens(tokens) + + flags = re.IGNORECASE if cli.is_ignoring_case else 0 + + # Get cursor column. + if document.cursor_position_row == lineno: + cursor_column = source_to_display(document.cursor_position_col) + else: + cursor_column = None + + for match in re.finditer(re.escape(search_text), line_text, flags=flags): + if cursor_column is not None: + on_cursor = match.start() <= cursor_column < match.end() + else: + on_cursor = False + + for i in range(match.start(), match.end()): + old_token, text = tokens[i] + if on_cursor: + tokens[i] = (old_token + searchmatch_current_token, tokens[i][1]) + else: + tokens[i] = (old_token + searchmatch_token, tokens[i][1]) + + return Transformation(tokens) + + +class HighlightSelectionProcessor(Processor): + """ + Processor that highlights the selection in the document. + """ + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + selected_token = (':', ) + Token.SelectedText + + # In case of selection, highlight all matches. + selection_at_line = document.selection_range_at_line(lineno) + + if selection_at_line: + from_, to = selection_at_line + from_ = source_to_display(from_) + to = source_to_display(to) + + tokens = explode_tokens(tokens) + + if from_ == 0 and to == 0 and len(tokens) == 0: + # When this is an empty line, insert a space in order to + # visualiase the selection. + return Transformation([(Token.SelectedText, ' ')]) + else: + for i in range(from_, to + 1): + if i < len(tokens): + old_token, old_text = tokens[i] + tokens[i] = (old_token + selected_token, old_text) + + return Transformation(tokens) + + +class PasswordProcessor(Processor): + """ + Processor that turns masks the input. (For passwords.) + + :param char: (string) Character to be used. "*" by default. + """ + def __init__(self, char='*'): + self.char = char + + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + tokens = [(token, self.char * len(text)) for token, text in tokens] + return Transformation(tokens) + + +class HighlightMatchingBracketProcessor(Processor): + """ + When the cursor is on or right after a bracket, it highlights the matching + bracket. + + :param max_cursor_distance: Only highlight matching brackets when the + cursor is within this distance. (From inside a `Processor`, we can't + know which lines will be visible on the screen. But we also don't want + to scan the whole document for matching brackets on each key press, so + we limit to this value.) + """ + _closing_braces = '])}>' + + def __init__(self, chars='[](){}<>', max_cursor_distance=1000): + self.chars = chars + self.max_cursor_distance = max_cursor_distance + + self._positions_cache = SimpleCache(maxsize=8) + + def _get_positions_to_highlight(self, document): + """ + Return a list of (row, col) tuples that need to be highlighted. + """ + # Try for the character under the cursor. + if document.current_char and document.current_char in self.chars: + pos = document.find_matching_bracket_position( + start_pos=document.cursor_position - self.max_cursor_distance, + end_pos=document.cursor_position + self.max_cursor_distance) + + # Try for the character before the cursor. + elif (document.char_before_cursor and document.char_before_cursor in + self._closing_braces and document.char_before_cursor in self.chars): + document = Document(document.text, document.cursor_position - 1) + + pos = document.find_matching_bracket_position( + start_pos=document.cursor_position - self.max_cursor_distance, + end_pos=document.cursor_position + self.max_cursor_distance) + else: + pos = None + + # Return a list of (row, col) tuples that need to be highlighted. + if pos: + pos += document.cursor_position # pos is relative. + row, col = document.translate_index_to_position(pos) + return [(row, col), (document.cursor_position_row, document.cursor_position_col)] + else: + return [] + + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + # Get the highlight positions. + key = (cli.render_counter, document.text, document.cursor_position) + positions = self._positions_cache.get( + key, lambda: self._get_positions_to_highlight(document)) + + # Apply if positions were found at this line. + if positions: + for row, col in positions: + if row == lineno: + col = source_to_display(col) + tokens = explode_tokens(tokens) + token, text = tokens[col] + + if col == document.cursor_position_col: + token += (':', ) + Token.MatchingBracket.Cursor + else: + token += (':', ) + Token.MatchingBracket.Other + + tokens[col] = (token, text) + + return Transformation(tokens) + + +class DisplayMultipleCursors(Processor): + """ + When we're in Vi block insert mode, display all the cursors. + """ + _insert_multiple = ViInsertMultipleMode() + + def __init__(self, buffer_name): + self.buffer_name = buffer_name + + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + buff = cli.buffers[self.buffer_name] + + if self._insert_multiple(cli): + positions = buff.multiple_cursor_positions + tokens = explode_tokens(tokens) + + # If any cursor appears on the current line, highlight that. + start_pos = document.translate_row_col_to_index(lineno, 0) + end_pos = start_pos + len(document.lines[lineno]) + + token_suffix = (':', ) + Token.MultipleCursors.Cursor + + for p in positions: + if start_pos <= p < end_pos: + column = source_to_display(p - start_pos) + + # Replace token. + token, text = tokens[column] + token += token_suffix + tokens[column] = (token, text) + elif p == end_pos: + tokens.append((token_suffix, ' ')) + + return Transformation(tokens) + else: + return Transformation(tokens) + + +class BeforeInput(Processor): + """ + Insert tokens before the input. + + :param get_tokens: Callable that takes a + :class:`~libs.prompt_toolkit.interface.CommandLineInterface` and returns the + list of tokens to be inserted. + """ + def __init__(self, get_tokens): + assert callable(get_tokens) + self.get_tokens = get_tokens + + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + if lineno == 0: + tokens_before = self.get_tokens(cli) + tokens = tokens_before + tokens + + shift_position = token_list_len(tokens_before) + source_to_display = lambda i: i + shift_position + display_to_source = lambda i: i - shift_position + else: + source_to_display = None + display_to_source = None + + return Transformation(tokens, source_to_display=source_to_display, + display_to_source=display_to_source) + + @classmethod + def static(cls, text, token=Token): + """ + Create a :class:`.BeforeInput` instance that always inserts the same + text. + """ + def get_static_tokens(cli): + return [(token, text)] + return cls(get_static_tokens) + + def __repr__(self): + return '%s(get_tokens=%r)' % ( + self.__class__.__name__, self.get_tokens) + + +class AfterInput(Processor): + """ + Insert tokens after the input. + + :param get_tokens: Callable that takes a + :class:`~libs.prompt_toolkit.interface.CommandLineInterface` and returns the + list of tokens to be appended. + """ + def __init__(self, get_tokens): + assert callable(get_tokens) + self.get_tokens = get_tokens + + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + # Insert tokens after the last line. + if lineno == document.line_count - 1: + return Transformation(tokens=tokens + self.get_tokens(cli)) + else: + return Transformation(tokens=tokens) + + @classmethod + def static(cls, text, token=Token): + """ + Create a :class:`.AfterInput` instance that always inserts the same + text. + """ + def get_static_tokens(cli): + return [(token, text)] + return cls(get_static_tokens) + + def __repr__(self): + return '%s(get_tokens=%r)' % ( + self.__class__.__name__, self.get_tokens) + + +class AppendAutoSuggestion(Processor): + """ + Append the auto suggestion to the input. + (The user can then press the right arrow the insert the suggestion.) + + :param buffer_name: The name of the buffer from where we should take the + auto suggestion. If not given, we take the current buffer. + """ + def __init__(self, buffer_name=None, token=Token.AutoSuggestion): + self.buffer_name = buffer_name + self.token = token + + def _get_buffer(self, cli): + if self.buffer_name: + return cli.buffers[self.buffer_name] + else: + return cli.current_buffer + + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + # Insert tokens after the last line. + if lineno == document.line_count - 1: + buffer = self._get_buffer(cli) + + if buffer.suggestion and buffer.document.is_cursor_at_the_end: + suggestion = buffer.suggestion.text + else: + suggestion = '' + + return Transformation(tokens=tokens + [(self.token, suggestion)]) + else: + return Transformation(tokens=tokens) + + +class ShowLeadingWhiteSpaceProcessor(Processor): + """ + Make leading whitespace visible. + + :param get_char: Callable that takes a :class:`CommandLineInterface` + instance and returns one character. + :param token: Token to be used. + """ + def __init__(self, get_char=None, token=Token.LeadingWhiteSpace): + assert get_char is None or callable(get_char) + + if get_char is None: + def get_char(cli): + if '\xb7'.encode(cli.output.encoding(), 'replace') == b'?': + return '.' + else: + return '\xb7' + + self.token = token + self.get_char = get_char + + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + # Walk through all te tokens. + if tokens and token_list_to_text(tokens).startswith(' '): + t = (self.token, self.get_char(cli)) + tokens = explode_tokens(tokens) + + for i in range(len(tokens)): + if tokens[i][1] == ' ': + tokens[i] = t + else: + break + + return Transformation(tokens) + + +class ShowTrailingWhiteSpaceProcessor(Processor): + """ + Make trailing whitespace visible. + + :param get_char: Callable that takes a :class:`CommandLineInterface` + instance and returns one character. + :param token: Token to be used. + """ + def __init__(self, get_char=None, token=Token.TrailingWhiteSpace): + assert get_char is None or callable(get_char) + + if get_char is None: + def get_char(cli): + if '\xb7'.encode(cli.output.encoding(), 'replace') == b'?': + return '.' + else: + return '\xb7' + + self.token = token + self.get_char = get_char + + + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + if tokens and tokens[-1][1].endswith(' '): + t = (self.token, self.get_char(cli)) + tokens = explode_tokens(tokens) + + # Walk backwards through all te tokens and replace whitespace. + for i in range(len(tokens) - 1, -1, -1): + char = tokens[i][1] + if char == ' ': + tokens[i] = t + else: + break + + return Transformation(tokens) + + +class TabsProcessor(Processor): + """ + Render tabs as spaces (instead of ^I) or make them visible (for instance, + by replacing them with dots.) + + :param tabstop: (Integer) Horizontal space taken by a tab. + :param get_char1: Callable that takes a `CommandLineInterface` and return a + character (text of length one). This one is used for the first space + taken by the tab. + :param get_char2: Like `get_char1`, but for the rest of the space. + """ + def __init__(self, tabstop=4, get_char1=None, get_char2=None, token=Token.Tab): + assert isinstance(tabstop, Integer) + assert get_char1 is None or callable(get_char1) + assert get_char2 is None or callable(get_char2) + + self.get_char1 = get_char1 or get_char2 or (lambda cli: '|') + self.get_char2 = get_char2 or get_char1 or (lambda cli: '\u2508') + self.tabstop = tabstop + self.token = token + + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + tabstop = int(self.tabstop) + token = self.token + + # Create separator for tabs. + separator1 = self.get_char1(cli) + separator2 = self.get_char2(cli) + + # Transform tokens. + tokens = explode_tokens(tokens) + + position_mappings = {} + result_tokens = [] + pos = 0 + + for i, token_and_text in enumerate(tokens): + position_mappings[i] = pos + + if token_and_text[1] == '\t': + # Calculate how many characters we have to insert. + count = tabstop - (pos % tabstop) + if count == 0: + count = tabstop + + # Insert tab. + result_tokens.append((token, separator1)) + result_tokens.append((token, separator2 * (count - 1))) + pos += count + else: + result_tokens.append(token_and_text) + pos += 1 + + position_mappings[len(tokens)] = pos + + def source_to_display(from_position): + " Maps original cursor position to the new one. " + return position_mappings[from_position] + + def display_to_source(display_pos): + " Maps display cursor position to the original one. " + position_mappings_reversed = dict((v, k) for k, v in position_mappings.items()) + + while display_pos >= 0: + try: + return position_mappings_reversed[display_pos] + except KeyError: + display_pos -= 1 + return 0 + + return Transformation( + result_tokens, + source_to_display=source_to_display, + display_to_source=display_to_source) + + +class ConditionalProcessor(Processor): + """ + Processor that applies another processor, according to a certain condition. + Example:: + + # Create a function that returns whether or not the processor should + # currently be applied. + def highlight_enabled(cli): + return true_or_false + + # Wrapt it in a `ConditionalProcessor` for usage in a `BufferControl`. + BufferControl(input_processors=[ + ConditionalProcessor(HighlightSearchProcessor(), + Condition(highlight_enabled))]) + + :param processor: :class:`.Processor` instance. + :param filter: :class:`~libs.prompt_toolkit.filters.CLIFilter` instance. + """ + def __init__(self, processor, filter): + assert isinstance(processor, Processor) + + self.processor = processor + self.filter = to_cli_filter(filter) + + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + # Run processor when enabled. + if self.filter(cli): + return self.processor.apply_transformation( + cli, document, lineno, source_to_display, tokens) + else: + return Transformation(tokens) + + def has_focus(self, cli): + if self.filter(cli): + return self.processor.has_focus(cli) + else: + return False + + def __repr__(self): + return '%s(processor=%r, filter=%r)' % ( + self.__class__.__name__, self.processor, self.filter) diff --git a/src/libs/prompt_toolkit/layout/prompt.py b/src/libs/prompt_toolkit/layout/prompt.py new file mode 100644 index 0000000..345b3c6 --- /dev/null +++ b/src/libs/prompt_toolkit/layout/prompt.py @@ -0,0 +1,111 @@ +from __future__ import unicode_literals + +from six import text_type + +from libs.prompt_toolkit.enums import IncrementalSearchDirection, SEARCH_BUFFER +from libs.prompt_toolkit.token import Token + +from .utils import token_list_len +from .processors import Processor, Transformation + +__all__ = ( + 'DefaultPrompt', +) + + +class DefaultPrompt(Processor): + """ + Default prompt. This one shows the 'arg' and reverse search like + Bash/readline normally do. + + There are two ways to instantiate a ``DefaultPrompt``. For a prompt + with a static message, do for instance:: + + prompt = DefaultPrompt.from_message('prompt> ') + + For a dynamic prompt, generated from a token list function:: + + def get_tokens(cli): + return [(Token.A, 'text'), (Token.B, 'text2')] + + prompt = DefaultPrompt(get_tokens) + """ + def __init__(self, get_tokens): + assert callable(get_tokens) + self.get_tokens = get_tokens + + @classmethod + def from_message(cls, message='> '): + """ + Create a default prompt with a static message text. + """ + assert isinstance(message, text_type) + + def get_message_tokens(cli): + return [(Token.Prompt, message)] + return cls(get_message_tokens) + + def apply_transformation(self, cli, document, lineno, source_to_display, tokens): + # Get text before cursor. + if cli.is_searching: + before = _get_isearch_tokens(cli) + + elif cli.input_processor.arg is not None: + before = _get_arg_tokens(cli) + + else: + before = self.get_tokens(cli) + + # Insert before buffer text. + shift_position = token_list_len(before) + + # Only show the prompt before the first line. For the following lines, + # only indent using spaces. + if lineno != 0: + before = [(Token.Prompt, ' ' * shift_position)] + + return Transformation( + tokens=before + tokens, + source_to_display=lambda i: i + shift_position, + display_to_source=lambda i: i - shift_position) + + def has_focus(self, cli): + # Obtain focus when the CLI is searching. + + # Usually, when using this `DefaultPrompt`, we don't have a + # `BufferControl` instance that displays the content of the search + # buffer. Instead the search text is displayed before the current text. + # So, we can still show the cursor here, while it's actually not this + # buffer that's focussed. + return cli.is_searching + + +def _get_isearch_tokens(cli): + def before(): + if cli.search_state.direction == IncrementalSearchDirection.BACKWARD: + text = 'reverse-i-search' + else: + text = 'i-search' + + return [(Token.Prompt.Search, '(%s)`' % text)] + + def text(): + return [(Token.Prompt.Search.Text, cli.buffers[SEARCH_BUFFER].text)] + + def after(): + return [(Token.Prompt.Search, '`: ')] + + return before() + text() + after() + + +def _get_arg_tokens(cli): + """ + Tokens for the arg-prompt. + """ + arg = cli.input_processor.arg + + return [ + (Token.Prompt.Arg, '(arg: '), + (Token.Prompt.Arg.Text, str(arg)), + (Token.Prompt.Arg, ') '), + ] diff --git a/src/libs/prompt_toolkit/layout/screen.py b/src/libs/prompt_toolkit/layout/screen.py new file mode 100644 index 0000000..099c085 --- /dev/null +++ b/src/libs/prompt_toolkit/layout/screen.py @@ -0,0 +1,151 @@ +from __future__ import unicode_literals + +from libs.prompt_toolkit.cache import FastDictCache +from libs.prompt_toolkit.token import Token +from libs.prompt_toolkit.utils import get_cwidth + +from collections import defaultdict, namedtuple + +__all__ = ( + 'Point', + 'Size', + 'Screen', + 'Char', +) + + +Point = namedtuple('Point', 'y x') +Size = namedtuple('Size', 'rows columns') + + +class Char(object): + """ + Represent a single character in a :class:`.Screen`. + + This should be considered immutable. + """ + __slots__ = ('char', 'token', 'width') + + # If we end up having one of these special control sequences in the input string, + # we should display them as follows: + # Usually this happens after a "quoted insert". + display_mappings = { + '\x00': '^@', # Control space + '\x01': '^A', + '\x02': '^B', + '\x03': '^C', + '\x04': '^D', + '\x05': '^E', + '\x06': '^F', + '\x07': '^G', + '\x08': '^H', + '\x09': '^I', + '\x0a': '^J', + '\x0b': '^K', + '\x0c': '^L', + '\x0d': '^M', + '\x0e': '^N', + '\x0f': '^O', + '\x10': '^P', + '\x11': '^Q', + '\x12': '^R', + '\x13': '^S', + '\x14': '^T', + '\x15': '^U', + '\x16': '^V', + '\x17': '^W', + '\x18': '^X', + '\x19': '^Y', + '\x1a': '^Z', + '\x1b': '^[', # Escape + '\x1c': '^\\', + '\x1d': '^]', + '\x1f': '^_', + '\x7f': '^?', # Backspace + } + + def __init__(self, char=' ', token=Token): + # If this character has to be displayed otherwise, take that one. + char = self.display_mappings.get(char, char) + + self.char = char + self.token = token + + # Calculate width. (We always need this, so better to store it directly + # as a member for performance.) + self.width = get_cwidth(char) + + def __eq__(self, other): + return self.char == other.char and self.token == other.token + + def __ne__(self, other): + # Not equal: We don't do `not char.__eq__` here, because of the + # performance of calling yet another function. + return self.char != other.char or self.token != other.token + + def __repr__(self): + return '%s(%r, %r)' % (self.__class__.__name__, self.char, self.token) + + +_CHAR_CACHE = FastDictCache(Char, size=1000 * 1000) +Transparent = Token.Transparent + + +class Screen(object): + """ + Two dimentional buffer of :class:`.Char` instances. + """ + def __init__(self, default_char=None, initial_width=0, initial_height=0): + if default_char is None: + default_char = _CHAR_CACHE[' ', Transparent] + + self.data_buffer = defaultdict(lambda: defaultdict(lambda: default_char)) + + #: Escape sequences to be injected. + self.zero_width_escapes = defaultdict(lambda: defaultdict(lambda: '')) + + #: Position of the cursor. + self.cursor_position = Point(y=0, x=0) + + #: Visibility of the cursor. + self.show_cursor = True + + #: (Optional) Where to position the menu. E.g. at the start of a completion. + #: (We can't use the cursor position, because we don't want the + #: completion menu to change its position when we browse through all the + #: completions.) + self.menu_position = None + + #: Currently used width/height of the screen. This will increase when + #: data is written to the screen. + self.width = initial_width or 0 + self.height = initial_height or 0 + + def replace_all_tokens(self, token): + """ + For all the characters in the screen. Set the token to the given `token`. + """ + b = self.data_buffer + + for y, row in b.items(): + for x, char in row.items(): + b[y][x] = _CHAR_CACHE[char.char, token] + + +class WritePosition(object): + def __init__(self, xpos, ypos, width, height, extended_height=None): + assert height >= 0 + assert extended_height is None or extended_height >= 0 + assert width >= 0 + # xpos and ypos can be negative. (A float can be partially visible.) + + self.xpos = xpos + self.ypos = ypos + self.width = width + self.height = height + self.extended_height = extended_height or height + + def __repr__(self): + return '%s(%r, %r, %r, %r, %r)' % ( + self.__class__.__name__, + self.xpos, self.ypos, self.width, self.height, self.extended_height) diff --git a/src/libs/prompt_toolkit/layout/toolbars.py b/src/libs/prompt_toolkit/layout/toolbars.py new file mode 100644 index 0000000..321c64f --- /dev/null +++ b/src/libs/prompt_toolkit/layout/toolbars.py @@ -0,0 +1,209 @@ +from __future__ import unicode_literals + +from ..enums import IncrementalSearchDirection + +from .processors import BeforeInput + +from .lexers import SimpleLexer +from .dimension import LayoutDimension +from .controls import BufferControl, TokenListControl, UIControl, UIContent +from .containers import Window, ConditionalContainer +from .screen import Char +from .utils import token_list_len +from libs.prompt_toolkit.enums import SEARCH_BUFFER, SYSTEM_BUFFER +from libs.prompt_toolkit.filters import HasFocus, HasArg, HasCompletions, HasValidationError, HasSearch, Always, IsDone +from libs.prompt_toolkit.token import Token + +__all__ = ( + 'TokenListToolbar', + 'ArgToolbar', + 'CompletionsToolbar', + 'SearchToolbar', + 'SystemToolbar', + 'ValidationToolbar', +) + + +class TokenListToolbar(ConditionalContainer): + def __init__(self, get_tokens, filter=Always(), **kw): + super(TokenListToolbar, self).__init__( + content=Window( + TokenListControl(get_tokens, **kw), + height=LayoutDimension.exact(1)), + filter=filter) + + +class SystemToolbarControl(BufferControl): + def __init__(self): + token = Token.Toolbar.System + + super(SystemToolbarControl, self).__init__( + buffer_name=SYSTEM_BUFFER, + default_char=Char(token=token), + lexer=SimpleLexer(token=token.Text), + input_processors=[BeforeInput.static('Shell command: ', token)],) + + +class SystemToolbar(ConditionalContainer): + def __init__(self): + super(SystemToolbar, self).__init__( + content=Window( + SystemToolbarControl(), + height=LayoutDimension.exact(1)), + filter=HasFocus(SYSTEM_BUFFER) & ~IsDone()) + + +class ArgToolbarControl(TokenListControl): + def __init__(self): + def get_tokens(cli): + arg = cli.input_processor.arg + if arg == '-': + arg = '-1' + + return [ + (Token.Toolbar.Arg, 'Repeat: '), + (Token.Toolbar.Arg.Text, arg), + ] + + super(ArgToolbarControl, self).__init__(get_tokens) + + +class ArgToolbar(ConditionalContainer): + def __init__(self): + super(ArgToolbar, self).__init__( + content=Window( + ArgToolbarControl(), + height=LayoutDimension.exact(1)), + filter=HasArg()) + + +class SearchToolbarControl(BufferControl): + """ + :param vi_mode: Display '/' and '?' instead of I-search. + """ + def __init__(self, vi_mode=False): + token = Token.Toolbar.Search + + def get_before_input(cli): + if not cli.is_searching: + text = '' + elif cli.search_state.direction == IncrementalSearchDirection.BACKWARD: + text = ('?' if vi_mode else 'I-search backward: ') + else: + text = ('/' if vi_mode else 'I-search: ') + + return [(token, text)] + + super(SearchToolbarControl, self).__init__( + buffer_name=SEARCH_BUFFER, + input_processors=[BeforeInput(get_before_input)], + default_char=Char(token=token), + lexer=SimpleLexer(token=token.Text)) + + +class SearchToolbar(ConditionalContainer): + def __init__(self, vi_mode=False): + super(SearchToolbar, self).__init__( + content=Window( + SearchToolbarControl(vi_mode=vi_mode), + height=LayoutDimension.exact(1)), + filter=HasSearch() & ~IsDone()) + + +class CompletionsToolbarControl(UIControl): + token = Token.Toolbar.Completions + + def create_content(self, cli, width, height): + complete_state = cli.current_buffer.complete_state + if complete_state: + completions = complete_state.current_completions + index = complete_state.complete_index # Can be None! + + # Width of the completions without the left/right arrows in the margins. + content_width = width - 6 + + # Booleans indicating whether we stripped from the left/right + cut_left = False + cut_right = False + + # Create Menu content. + tokens = [] + + for i, c in enumerate(completions): + # When there is no more place for the next completion + if token_list_len(tokens) + len(c.display) >= content_width: + # If the current one was not yet displayed, page to the next sequence. + if i <= (index or 0): + tokens = [] + cut_left = True + # If the current one is visible, stop here. + else: + cut_right = True + break + + tokens.append((self.token.Completion.Current if i == index else self.token.Completion, c.display)) + tokens.append((self.token, ' ')) + + # Extend/strip until the content width. + tokens.append((self.token, ' ' * (content_width - token_list_len(tokens)))) + tokens = tokens[:content_width] + + # Return tokens + all_tokens = [ + (self.token, ' '), + (self.token.Arrow, '<' if cut_left else ' '), + (self.token, ' '), + ] + tokens + [ + (self.token, ' '), + (self.token.Arrow, '>' if cut_right else ' '), + (self.token, ' '), + ] + else: + all_tokens = [] + + def get_line(i): + return all_tokens + + return UIContent(get_line=get_line, line_count=1) + + +class CompletionsToolbar(ConditionalContainer): + def __init__(self, extra_filter=Always()): + super(CompletionsToolbar, self).__init__( + content=Window( + CompletionsToolbarControl(), + height=LayoutDimension.exact(1)), + filter=HasCompletions() & ~IsDone() & extra_filter) + + +class ValidationToolbarControl(TokenListControl): + def __init__(self, show_position=False): + token = Token.Toolbar.Validation + + def get_tokens(cli): + buffer = cli.current_buffer + + if buffer.validation_error: + row, column = buffer.document.translate_index_to_position( + buffer.validation_error.cursor_position) + + if show_position: + text = '%s (line=%s column=%s)' % ( + buffer.validation_error.message, row + 1, column + 1) + else: + text = buffer.validation_error.message + + return [(token, text)] + else: + return [] + + super(ValidationToolbarControl, self).__init__(get_tokens) + + +class ValidationToolbar(ConditionalContainer): + def __init__(self, show_position=False): + super(ValidationToolbar, self).__init__( + content=Window( + ValidationToolbarControl(show_position=show_position), + height=LayoutDimension.exact(1)), + filter=HasValidationError() & ~IsDone()) diff --git a/src/libs/prompt_toolkit/layout/utils.py b/src/libs/prompt_toolkit/layout/utils.py new file mode 100644 index 0000000..6e9f01a --- /dev/null +++ b/src/libs/prompt_toolkit/layout/utils.py @@ -0,0 +1,181 @@ +from __future__ import unicode_literals + +from libs.prompt_toolkit.utils import get_cwidth +from libs.prompt_toolkit.token import Token + +__all__ = ( + 'token_list_len', + 'token_list_width', + 'token_list_to_text', + 'explode_tokens', + 'split_lines', + 'find_window_for_buffer_name', +) + + +def token_list_len(tokenlist): + """ + Return the amount of characters in this token list. + + :param tokenlist: List of (token, text) or (token, text, mouse_handler) + tuples. + """ + ZeroWidthEscape = Token.ZeroWidthEscape + return sum(len(item[1]) for item in tokenlist if item[0] != ZeroWidthEscape) + + +def token_list_width(tokenlist): + """ + Return the character width of this token list. + (Take double width characters into account.) + + :param tokenlist: List of (token, text) or (token, text, mouse_handler) + tuples. + """ + ZeroWidthEscape = Token.ZeroWidthEscape + return sum(get_cwidth(c) for item in tokenlist for c in item[1] if item[0] != ZeroWidthEscape) + + +def token_list_to_text(tokenlist): + """ + Concatenate all the text parts again. + """ + ZeroWidthEscape = Token.ZeroWidthEscape + return ''.join(item[1] for item in tokenlist if item[0] != ZeroWidthEscape) + + +def iter_token_lines(tokenlist): + """ + Iterator that yields tokenlists for each line. + """ + line = [] + for token, c in explode_tokens(tokenlist): + line.append((token, c)) + + if c == '\n': + yield line + line = [] + + yield line + + +def split_lines(tokenlist): + """ + Take a single list of (Token, text) tuples and yield one such list for each + line. Just like str.split, this will yield at least one item. + + :param tokenlist: List of (token, text) or (token, text, mouse_handler) + tuples. + """ + line = [] + + for item in tokenlist: + # For (token, text) tuples. + if len(item) == 2: + token, string = item + parts = string.split('\n') + + for part in parts[:-1]: + if part: + line.append((token, part)) + yield line + line = [] + + line.append((token, parts[-1])) + # Note that parts[-1] can be empty, and that's fine. It happens + # in the case of [(Token.SetCursorPosition, '')]. + + # For (token, text, mouse_handler) tuples. + # I know, partly copy/paste, but understandable and more efficient + # than many tests. + else: + token, string, mouse_handler = item + parts = string.split('\n') + + for part in parts[:-1]: + if part: + line.append((token, part, mouse_handler)) + yield line + line = [] + + line.append((token, parts[-1], mouse_handler)) + + # Always yield the last line, even when this is an empty line. This ensures + # that when `tokenlist` ends with a newline character, an additional empty + # line is yielded. (Otherwise, there's no way to differentiate between the + # cases where `tokenlist` does and doesn't end with a newline.) + yield line + + +class _ExplodedList(list): + """ + Wrapper around a list, that marks it as 'exploded'. + + As soon as items are added or the list is extended, the new items are + automatically exploded as well. + """ + def __init__(self, *a, **kw): + super(_ExplodedList, self).__init__(*a, **kw) + self.exploded = True + + def append(self, item): + self.extend([item]) + + def extend(self, lst): + super(_ExplodedList, self).extend(explode_tokens(lst)) + + def insert(self, index, item): + raise NotImplementedError # TODO + + # TODO: When creating a copy() or [:], return also an _ExplodedList. + + def __setitem__(self, index, value): + """ + Ensure that when `(Token, 'long string')` is set, the string will be + exploded. + """ + if not isinstance(index, slice): + index = slice(index, index + 1) + value = explode_tokens([value]) + super(_ExplodedList, self).__setitem__(index, value) + + +def explode_tokens(tokenlist): + """ + Turn a list of (token, text) tuples into another list where each string is + exactly one character. + + It should be fine to call this function several times. Calling this on a + list that is already exploded, is a null operation. + + :param tokenlist: List of (token, text) tuples. + """ + # When the tokenlist is already exploded, don't explode again. + if getattr(tokenlist, 'exploded', False): + return tokenlist + + result = [] + + for token, string in tokenlist: + for c in string: + result.append((token, c)) + + return _ExplodedList(result) + + +def find_window_for_buffer_name(cli, buffer_name): + """ + Look for a :class:`~libs.prompt_toolkit.layout.containers.Window` in the Layout + that contains the :class:`~libs.prompt_toolkit.layout.controls.BufferControl` + for the given buffer and return it. If no such Window is found, return None. + """ + from libs.prompt_toolkit.interface import CommandLineInterface + assert isinstance(cli, CommandLineInterface) + + from .containers import Window + from .controls import BufferControl + + for l in cli.layout.walk(cli): + if isinstance(l, Window) and isinstance(l.content, BufferControl): + if l.content.buffer_name == buffer_name: + return l diff --git a/src/libs/prompt_toolkit/mouse_events.py b/src/libs/prompt_toolkit/mouse_events.py new file mode 100644 index 0000000..a0a16b9 --- /dev/null +++ b/src/libs/prompt_toolkit/mouse_events.py @@ -0,0 +1,48 @@ +""" +Mouse events. + + +How it works +------------ + +The renderer has a 2 dimensional grid of mouse event handlers. +(`libs.prompt_toolkit.layout.MouseHandlers`.) When the layout is rendered, the +`Window` class will make sure that this grid will also be filled with +callbacks. For vt100 terminals, mouse events are received through stdin, just +like any other key press. There is a handler among the key bindings that +catches these events and forwards them to such a mouse event handler. It passes +through the `Window` class where the coordinates are translated from absolute +coordinates to coordinates relative to the user control, and there +`UIControl.mouse_handler` is called. +""" +from __future__ import unicode_literals + +__all__ = ( + 'MouseEventType', + 'MouseEvent' +) + + +class MouseEventType: + MOUSE_UP = 'MOUSE_UP' + MOUSE_DOWN = 'MOUSE_DOWN' + SCROLL_UP = 'SCROLL_UP' + SCROLL_DOWN = 'SCROLL_DOWN' + + +MouseEventTypes = MouseEventType # Deprecated: plural for backwards compatibility. + + +class MouseEvent(object): + """ + Mouse event, sent to `UIControl.mouse_handler`. + + :param position: `Point` instance. + :param event_type: `MouseEventType`. + """ + def __init__(self, position, event_type): + self.position = position + self.event_type = event_type + + def __repr__(self): + return 'MouseEvent(%r, %r)' % (self.position, self.event_type) diff --git a/src/libs/prompt_toolkit/output.py b/src/libs/prompt_toolkit/output.py new file mode 100644 index 0000000..5442d14 --- /dev/null +++ b/src/libs/prompt_toolkit/output.py @@ -0,0 +1,192 @@ +""" +Interface for an output. +""" +from __future__ import unicode_literals +from abc import ABCMeta, abstractmethod +from six import with_metaclass +from libs.prompt_toolkit.layout.screen import Size + +__all__ = ( + 'Output', +) + + +class Output(with_metaclass(ABCMeta, object)): + """ + Base class defining the output interface for a + :class:`~libs.prompt_toolkit.renderer.Renderer`. + + Actual implementations are + :class:`~libs.prompt_toolkit.terminal.vt100_output.Vt100_Output` and + :class:`~libs.prompt_toolkit.terminal.win32_output.Win32Output`. + """ + @abstractmethod + def fileno(self): + " Return the file descriptor to which we can write for the output. " + + @abstractmethod + def encoding(self): + """ + Return the encoding for this output, e.g. 'utf-8'. + (This is used mainly to know which characters are supported by the + output the data, so that the UI can provide alternatives, when + required.) + """ + + @abstractmethod + def write(self, data): + " Write text (Terminal escape sequences will be removed/escaped.) " + + @abstractmethod + def write_raw(self, data): + " Write text. " + + @abstractmethod + def set_title(self, title): + " Set terminal title. " + + @abstractmethod + def clear_title(self): + " Clear title again. (or restore previous title.) " + + @abstractmethod + def flush(self): + " Write to output stream and flush. " + + @abstractmethod + def erase_screen(self): + """ + Erases the screen with the background colour and moves the cursor to + home. + """ + + @abstractmethod + def enter_alternate_screen(self): + " Go to the alternate screen buffer. (For full screen applications). " + + @abstractmethod + def quit_alternate_screen(self): + " Leave the alternate screen buffer. " + + @abstractmethod + def enable_mouse_support(self): + " Enable mouse. " + + @abstractmethod + def disable_mouse_support(self): + " Disable mouse. " + + @abstractmethod + def erase_end_of_line(self): + """ + Erases from the current cursor position to the end of the current line. + """ + + @abstractmethod + def erase_down(self): + """ + Erases the screen from the current line down to the bottom of the + screen. + """ + + @abstractmethod + def reset_attributes(self): + " Reset color and styling attributes. " + + @abstractmethod + def set_attributes(self, attrs): + " Set new color and styling attributes. " + + @abstractmethod + def disable_autowrap(self): + " Disable auto line wrapping. " + + @abstractmethod + def enable_autowrap(self): + " Enable auto line wrapping. " + + @abstractmethod + def cursor_goto(self, row=0, column=0): + " Move cursor position. " + + @abstractmethod + def cursor_up(self, amount): + " Move cursor `amount` place up. " + + @abstractmethod + def cursor_down(self, amount): + " Move cursor `amount` place down. " + + @abstractmethod + def cursor_forward(self, amount): + " Move cursor `amount` place forward. " + + @abstractmethod + def cursor_backward(self, amount): + " Move cursor `amount` place backward. " + + @abstractmethod + def hide_cursor(self): + " Hide cursor. " + + @abstractmethod + def show_cursor(self): + " Show cursor. " + + def ask_for_cpr(self): + """ + Asks for a cursor position report (CPR). + (VT100 only.) + """ + + def bell(self): + " Sound bell. " + + def enable_bracketed_paste(self): + " For vt100 only. " + + def disable_bracketed_paste(self): + " For vt100 only. " + + +class DummyOutput(Output): + """ + For testing. An output class that doesn't render anything. + """ + def fileno(self): + " There is no sensible default for fileno(). " + raise NotImplementedError + + def encoding(self): + return 'utf-8' + + def write(self, data): pass + def write_raw(self, data): pass + def set_title(self, title): pass + def clear_title(self): pass + def flush(self): pass + def erase_screen(self): pass + def enter_alternate_screen(self): pass + def quit_alternate_screen(self): pass + def enable_mouse_support(self): pass + def disable_mouse_support(self): pass + def erase_end_of_line(self): pass + def erase_down(self): pass + def reset_attributes(self): pass + def set_attributes(self, attrs): pass + def disable_autowrap(self): pass + def enable_autowrap(self): pass + def cursor_goto(self, row=0, column=0): pass + def cursor_up(self, amount): pass + def cursor_down(self, amount): pass + def cursor_forward(self, amount): pass + def cursor_backward(self, amount): pass + def hide_cursor(self): pass + def show_cursor(self): pass + def ask_for_cpr(self): pass + def bell(self): pass + def enable_bracketed_paste(self): pass + def disable_bracketed_paste(self): pass + + def get_size(self): + return Size(rows=40, columns=80) diff --git a/src/libs/prompt_toolkit/reactive.py b/src/libs/prompt_toolkit/reactive.py new file mode 100644 index 0000000..ec3aa06 --- /dev/null +++ b/src/libs/prompt_toolkit/reactive.py @@ -0,0 +1,56 @@ +""" +Prompt_toolkit is designed a way that the amount of changing state is reduced +to a minimum. Where possible, code is written in a pure functional way. In +general, this results in code where the flow is very easy to follow: the value +of a variable can be deducted from its first assignment. + +However, often, practicality and performance beat purity and some classes still +have a changing state. In order to not having to care too much about +transferring states between several components we use some reactive +programming. Actually some kind of data binding. + +We introduce two types: + +- Filter: for binding a boolean state. They can be chained using & and | + operators. Have a look in the ``filters`` module. Resolving the actual value + of a filter happens by calling it. + +- Integer: for binding integer values. Reactive operations (like addition and + substraction) are not suppported. Resolving the actual value happens by + casting it to int, like ``int(integer)``. This way, it is possible to use + normal integers as well for static values. +""" +from __future__ import unicode_literals +from abc import ABCMeta, abstractmethod +from six import with_metaclass + + +class Integer(with_metaclass(ABCMeta, object)): + """ + Reactive integer -- anything that can be resolved to an ``int``. + """ + @abstractmethod + def __int__(self): + return 0 + + @classmethod + def from_callable(cls, func): + """ + Create an Integer-like object that calls the given function when it is + resolved to an int. + """ + return _IntegerFromCallable(func) + + +Integer.register(int) + + +class _IntegerFromCallable(Integer): + def __init__(self, func=0): + self.func = func + + def __repr__(self): + return 'Integer.from_callable(%r)' % self.func + + def __int__(self): + return int(self.func()) diff --git a/src/libs/prompt_toolkit/renderer.py b/src/libs/prompt_toolkit/renderer.py new file mode 100644 index 0000000..7fc295b --- /dev/null +++ b/src/libs/prompt_toolkit/renderer.py @@ -0,0 +1,526 @@ +""" +Renders the command line on the console. +(Redraws parts of the input line that were changed.) +""" +from __future__ import unicode_literals + +from libs.prompt_toolkit.filters import to_cli_filter +from libs.prompt_toolkit.layout.mouse_handlers import MouseHandlers +from libs.prompt_toolkit.layout.screen import Point, Screen, WritePosition +from libs.prompt_toolkit.output import Output +from libs.prompt_toolkit.styles import Style +from libs.prompt_toolkit.token import Token +from libs.prompt_toolkit.utils import is_windows + +from six.moves import range + +__all__ = ( + 'Renderer', + 'print_tokens', +) + + +def _output_screen_diff(output, screen, current_pos, previous_screen=None, last_token=None, + is_done=False, attrs_for_token=None, size=None, previous_width=0): # XXX: drop is_done + """ + Render the diff between this screen and the previous screen. + + This takes two `Screen` instances. The one that represents the output like + it was during the last rendering and one that represents the current + output raster. Looking at these two `Screen` instances, this function will + render the difference by calling the appropriate methods of the `Output` + object that only paint the changes to the terminal. + + This is some performance-critical code which is heavily optimized. + Don't change things without profiling first. + + :param current_pos: Current cursor position. + :param last_token: `Token` instance that represents the output attributes of + the last drawn character. (Color/attributes.) + :param attrs_for_token: :class:`._TokenToAttrsCache` instance. + :param width: The width of the terminal. + :param prevous_width: The width of the terminal during the last rendering. + """ + width, height = size.columns, size.rows + + #: Remember the last printed character. + last_token = [last_token] # nonlocal + + #: Variable for capturing the output. + write = output.write + write_raw = output.write_raw + + # Create locals for the most used output methods. + # (Save expensive attribute lookups.) + _output_set_attributes = output.set_attributes + _output_reset_attributes = output.reset_attributes + _output_cursor_forward = output.cursor_forward + _output_cursor_up = output.cursor_up + _output_cursor_backward = output.cursor_backward + + # Hide cursor before rendering. (Avoid flickering.) + output.hide_cursor() + + def reset_attributes(): + " Wrapper around Output.reset_attributes. " + _output_reset_attributes() + last_token[0] = None # Forget last char after resetting attributes. + + def move_cursor(new): + " Move cursor to this `new` point. Returns the given Point. " + current_x, current_y = current_pos.x, current_pos.y + + if new.y > current_y: + # Use newlines instead of CURSOR_DOWN, because this meight add new lines. + # CURSOR_DOWN will never create new lines at the bottom. + # Also reset attributes, otherwise the newline could draw a + # background color. + reset_attributes() + write('\r\n' * (new.y - current_y)) + current_x = 0 + _output_cursor_forward(new.x) + return new + elif new.y < current_y: + _output_cursor_up(current_y - new.y) + + if current_x >= width - 1: + write('\r') + _output_cursor_forward(new.x) + elif new.x < current_x or current_x >= width - 1: + _output_cursor_backward(current_x - new.x) + elif new.x > current_x: + _output_cursor_forward(new.x - current_x) + + return new + + def output_char(char): + """ + Write the output of this character. + """ + # If the last printed character has the same token, it also has the + # same style, so we don't output it. + the_last_token = last_token[0] + + if the_last_token and the_last_token == char.token: + write(char.char) + else: + _output_set_attributes(attrs_for_token[char.token]) + write(char.char) + last_token[0] = char.token + + # Disable autowrap + if not previous_screen: + output.disable_autowrap() + reset_attributes() + + # When the previous screen has a different size, redraw everything anyway. + # Also when we are done. (We meight take up less rows, so clearing is important.) + if is_done or not previous_screen or previous_width != width: # XXX: also consider height?? + current_pos = move_cursor(Point(0, 0)) + reset_attributes() + output.erase_down() + + previous_screen = Screen() + + # Get height of the screen. + # (height changes as we loop over data_buffer, so remember the current value.) + # (Also make sure to clip the height to the size of the output.) + current_height = min(screen.height, height) + + # Loop over the rows. + row_count = min(max(screen.height, previous_screen.height), height) + c = 0 # Column counter. + + for y in range(row_count): + new_row = screen.data_buffer[y] + previous_row = previous_screen.data_buffer[y] + zero_width_escapes_row = screen.zero_width_escapes[y] + + new_max_line_len = min(width - 1, max(new_row.keys()) if new_row else 0) + previous_max_line_len = min(width - 1, max(previous_row.keys()) if previous_row else 0) + + # Loop over the columns. + c = 0 + while c < new_max_line_len + 1: + new_char = new_row[c] + old_char = previous_row[c] + char_width = (new_char.width or 1) + + # When the old and new character at this position are different, + # draw the output. (Because of the performance, we don't call + # `Char.__ne__`, but inline the same expression.) + if new_char.char != old_char.char or new_char.token != old_char.token: + current_pos = move_cursor(Point(y=y, x=c)) + + # Send injected escape sequences to output. + if c in zero_width_escapes_row: + write_raw(zero_width_escapes_row[c]) + + output_char(new_char) + current_pos = current_pos._replace(x=current_pos.x + char_width) + + c += char_width + + # If the new line is shorter, trim it. + if previous_screen and new_max_line_len < previous_max_line_len: + current_pos = move_cursor(Point(y=y, x=new_max_line_len+1)) + reset_attributes() + output.erase_end_of_line() + + # Correctly reserve vertical space as required by the layout. + # When this is a new screen (drawn for the first time), or for some reason + # higher than the previous one. Move the cursor once to the bottom of the + # output. That way, we're sure that the terminal scrolls up, even when the + # lower lines of the canvas just contain whitespace. + + # The most obvious reason that we actually want this behaviour is the avoid + # the artifact of the input scrolling when the completion menu is shown. + # (If the scrolling is actually wanted, the layout can still be build in a + # way to behave that way by setting a dynamic height.) + if current_height > previous_screen.height: + current_pos = move_cursor(Point(y=current_height - 1, x=0)) + + # Move cursor: + if is_done: + current_pos = move_cursor(Point(y=current_height, x=0)) + output.erase_down() + else: + current_pos = move_cursor(screen.cursor_position) + + if is_done: + output.enable_autowrap() + + # Always reset the color attributes. This is important because a background + # thread could print data to stdout and we want that to be displayed in the + # default colors. (Also, if a background color has been set, many terminals + # give weird artifacs on resize events.) + reset_attributes() + + if screen.show_cursor or is_done: + output.show_cursor() + + return current_pos, last_token[0] + + +class HeightIsUnknownError(Exception): + " Information unavailable. Did not yet receive the CPR response. " + + +class _TokenToAttrsCache(dict): + """ + A cache structure that maps Pygments Tokens to :class:`.Attr`. + (This is an important speed up.) + """ + def __init__(self, get_style_for_token): + self.get_style_for_token = get_style_for_token + + def __missing__(self, token): + try: + result = self.get_style_for_token(token) + except KeyError: + result = None + + self[token] = result + return result + + +class Renderer(object): + """ + Typical usage: + + :: + + output = Vt100_Output.from_pty(sys.stdout) + r = Renderer(style, output) + r.render(cli, layout=...) + """ + def __init__(self, style, output, use_alternate_screen=False, mouse_support=False): + assert isinstance(style, Style) + assert isinstance(output, Output) + + self.style = style + self.output = output + self.use_alternate_screen = use_alternate_screen + self.mouse_support = to_cli_filter(mouse_support) + + self._in_alternate_screen = False + self._mouse_support_enabled = False + self._bracketed_paste_enabled = False + + # Waiting for CPR flag. True when we send the request, but didn't got a + # response. + self.waiting_for_cpr = False + + self.reset(_scroll=True) + + def reset(self, _scroll=False, leave_alternate_screen=True): + # Reset position + self._cursor_pos = Point(x=0, y=0) + + # Remember the last screen instance between renderers. This way, + # we can create a `diff` between two screens and only output the + # difference. It's also to remember the last height. (To show for + # instance a toolbar at the bottom position.) + self._last_screen = None + self._last_size = None + self._last_token = None + + # When the style hash changes, we have to do a full redraw as well as + # clear the `_attrs_for_token` dictionary. + self._last_style_hash = None + self._attrs_for_token = None + + # Default MouseHandlers. (Just empty.) + self.mouse_handlers = MouseHandlers() + + # Remember the last title. Only set the title when it changes. + self._last_title = None + + #: Space from the top of the layout, until the bottom of the terminal. + #: We don't know this until a `report_absolute_cursor_row` call. + self._min_available_height = 0 + + # In case of Windown, also make sure to scroll to the current cursor + # position. (Only when rendering the first time.) + if is_windows() and _scroll: + self.output.scroll_buffer_to_prompt() + + # Quit alternate screen. + if self._in_alternate_screen and leave_alternate_screen: + self.output.quit_alternate_screen() + self._in_alternate_screen = False + + # Disable mouse support. + if self._mouse_support_enabled: + self.output.disable_mouse_support() + self._mouse_support_enabled = False + + # Disable bracketed paste. + if self._bracketed_paste_enabled: + self.output.disable_bracketed_paste() + self._bracketed_paste_enabled = False + + # Flush output. `disable_mouse_support` needs to write to stdout. + self.output.flush() + + @property + def height_is_known(self): + """ + True when the height from the cursor until the bottom of the terminal + is known. (It's often nicer to draw bottom toolbars only if the height + is known, in order to avoid flickering when the CPR response arrives.) + """ + return self.use_alternate_screen or self._min_available_height > 0 or \ + is_windows() # On Windows, we don't have to wait for a CPR. + + @property + def rows_above_layout(self): + """ + Return the number of rows visible in the terminal above the layout. + """ + if self._in_alternate_screen: + return 0 + elif self._min_available_height > 0: + total_rows = self.output.get_size().rows + last_screen_height = self._last_screen.height if self._last_screen else 0 + return total_rows - max(self._min_available_height, last_screen_height) + else: + raise HeightIsUnknownError('Rows above layout is unknown.') + + def request_absolute_cursor_position(self): + """ + Get current cursor position. + For vt100: Do CPR request. (answer will arrive later.) + For win32: Do API call. (Answer comes immediately.) + """ + # Only do this request when the cursor is at the top row. (after a + # clear or reset). We will rely on that in `report_absolute_cursor_row`. + assert self._cursor_pos.y == 0 + + # For Win32, we have an API call to get the number of rows below the + # cursor. + if is_windows(): + self._min_available_height = self.output.get_rows_below_cursor_position() + else: + if self.use_alternate_screen: + self._min_available_height = self.output.get_size().rows + else: + # Asks for a cursor position report (CPR). + self.waiting_for_cpr = True + self.output.ask_for_cpr() + + def report_absolute_cursor_row(self, row): + """ + To be called when we know the absolute cursor position. + (As an answer of a "Cursor Position Request" response.) + """ + # Calculate the amount of rows from the cursor position until the + # bottom of the terminal. + total_rows = self.output.get_size().rows + rows_below_cursor = total_rows - row + 1 + + # Set the + self._min_available_height = rows_below_cursor + + self.waiting_for_cpr = False + + def render(self, cli, layout, is_done=False): + """ + Render the current interface to the output. + + :param is_done: When True, put the cursor at the end of the interface. We + won't print any changes to this part. + """ + output = self.output + + # Enter alternate screen. + if self.use_alternate_screen and not self._in_alternate_screen: + self._in_alternate_screen = True + output.enter_alternate_screen() + + # Enable bracketed paste. + if not self._bracketed_paste_enabled: + self.output.enable_bracketed_paste() + self._bracketed_paste_enabled = True + + # Enable/disable mouse support. + needs_mouse_support = self.mouse_support(cli) + + if needs_mouse_support and not self._mouse_support_enabled: + output.enable_mouse_support() + self._mouse_support_enabled = True + + elif not needs_mouse_support and self._mouse_support_enabled: + output.disable_mouse_support() + self._mouse_support_enabled = False + + # Create screen and write layout to it. + size = output.get_size() + screen = Screen() + screen.show_cursor = False # Hide cursor by default, unless one of the + # containers decides to display it. + mouse_handlers = MouseHandlers() + + if is_done: + height = 0 # When we are done, we don't necessary want to fill up until the bottom. + else: + height = self._last_screen.height if self._last_screen else 0 + height = max(self._min_available_height, height) + + # When te size changes, don't consider the previous screen. + if self._last_size != size: + self._last_screen = None + + # When we render using another style, do a full repaint. (Forget about + # the previous rendered screen.) + # (But note that we still use _last_screen to calculate the height.) + if self.style.invalidation_hash() != self._last_style_hash: + self._last_screen = None + self._attrs_for_token = None + if self._attrs_for_token is None: + self._attrs_for_token = _TokenToAttrsCache(self.style.get_attrs_for_token) + self._last_style_hash = self.style.invalidation_hash() + + layout.write_to_screen(cli, screen, mouse_handlers, WritePosition( + xpos=0, + ypos=0, + width=size.columns, + height=(size.rows if self.use_alternate_screen else height), + extended_height=size.rows, + )) + + # When grayed. Replace all tokens in the new screen. + if cli.is_aborting or cli.is_exiting: + screen.replace_all_tokens(Token.Aborted) + + # Process diff and write to output. + self._cursor_pos, self._last_token = _output_screen_diff( + output, screen, self._cursor_pos, + self._last_screen, self._last_token, is_done, + attrs_for_token=self._attrs_for_token, + size=size, + previous_width=(self._last_size.columns if self._last_size else 0)) + self._last_screen = screen + self._last_size = size + self.mouse_handlers = mouse_handlers + + # Write title if it changed. + new_title = cli.terminal_title + + if new_title != self._last_title: + if new_title is None: + self.output.clear_title() + else: + self.output.set_title(new_title) + self._last_title = new_title + + output.flush() + + def erase(self, leave_alternate_screen=True, erase_title=True): + """ + Hide all output and put the cursor back at the first line. This is for + instance used for running a system command (while hiding the CLI) and + later resuming the same CLI.) + + :param leave_alternate_screen: When True, and when inside an alternate + screen buffer, quit the alternate screen. + :param erase_title: When True, clear the title from the title bar. + """ + output = self.output + + output.cursor_backward(self._cursor_pos.x) + output.cursor_up(self._cursor_pos.y) + output.erase_down() + output.reset_attributes() + output.flush() + + # Erase title. + if self._last_title and erase_title: + output.clear_title() + + self.reset(leave_alternate_screen=leave_alternate_screen) + + def clear(self): + """ + Clear screen and go to 0,0 + """ + # Erase current output first. + self.erase() + + # Send "Erase Screen" command and go to (0, 0). + output = self.output + + output.erase_screen() + output.cursor_goto(0, 0) + output.flush() + + self.request_absolute_cursor_position() + + +def print_tokens(output, tokens, style): + """ + Print a list of (Token, text) tuples in the given style to the output. + """ + assert isinstance(output, Output) + assert isinstance(style, Style) + + # Reset first. + output.reset_attributes() + output.enable_autowrap() + + # Print all (token, text) tuples. + attrs_for_token = _TokenToAttrsCache(style.get_attrs_for_token) + + for token, text in tokens: + attrs = attrs_for_token[token] + + if attrs: + output.set_attributes(attrs) + else: + output.reset_attributes() + + output.write(text) + + # Reset again. + output.reset_attributes() + output.flush() diff --git a/src/libs/prompt_toolkit/search_state.py b/src/libs/prompt_toolkit/search_state.py new file mode 100644 index 0000000..3c494ea --- /dev/null +++ b/src/libs/prompt_toolkit/search_state.py @@ -0,0 +1,36 @@ +from .enums import IncrementalSearchDirection +from .filters import to_simple_filter + +__all__ = ( + 'SearchState', +) + + +class SearchState(object): + """ + A search 'query'. + """ + __slots__ = ('text', 'direction', 'ignore_case') + + def __init__(self, text='', direction=IncrementalSearchDirection.FORWARD, ignore_case=False): + ignore_case = to_simple_filter(ignore_case) + + self.text = text + self.direction = direction + self.ignore_case = ignore_case + + def __repr__(self): + return '%s(%r, direction=%r, ignore_case=%r)' % ( + self.__class__.__name__, self.text, self.direction, self.ignore_case) + + def __invert__(self): + """ + Create a new SearchState where backwards becomes forwards and the other + way around. + """ + if self.direction == IncrementalSearchDirection.BACKWARD: + direction = IncrementalSearchDirection.FORWARD + else: + direction = IncrementalSearchDirection.BACKWARD + + return SearchState(text=self.text, direction=direction, ignore_case=self.ignore_case) diff --git a/src/libs/prompt_toolkit/selection.py b/src/libs/prompt_toolkit/selection.py new file mode 100644 index 0000000..6582921 --- /dev/null +++ b/src/libs/prompt_toolkit/selection.py @@ -0,0 +1,47 @@ +""" +Data structures for the selection. +""" +from __future__ import unicode_literals + +__all__ = ( + 'SelectionType', + 'PasteMode', + 'SelectionState', +) + + +class SelectionType(object): + """ + Type of selection. + """ + #: Characters. (Visual in Vi.) + CHARACTERS = 'CHARACTERS' + + #: Whole lines. (Visual-Line in Vi.) + LINES = 'LINES' + + #: A block selection. (Visual-Block in Vi.) + BLOCK = 'BLOCK' + + +class PasteMode(object): + EMACS = 'EMACS' # Yank like emacs. + VI_AFTER = 'VI_AFTER' # When pressing 'p' in Vi. + VI_BEFORE = 'VI_BEFORE' # When pressing 'P' in Vi. + + +class SelectionState(object): + """ + State of the current selection. + + :param original_cursor_position: int + :param type: :class:`~.SelectionType` + """ + def __init__(self, original_cursor_position=0, type=SelectionType.CHARACTERS): + self.original_cursor_position = original_cursor_position + self.type = type + + def __repr__(self): + return '%s(original_cursor_position=%r, type=%r)' % ( + self.__class__.__name__, + self.original_cursor_position, self.type) diff --git a/src/libs/prompt_toolkit/shortcuts.py b/src/libs/prompt_toolkit/shortcuts.py new file mode 100644 index 0000000..4e5d0ea --- /dev/null +++ b/src/libs/prompt_toolkit/shortcuts.py @@ -0,0 +1,717 @@ +""" +Shortcuts for retrieving input from the user. + +If you are using this library for retrieving some input from the user (as a +pure Python replacement for GNU readline), probably for 90% of the use cases, +the :func:`.prompt` function is all you need. It's the easiest shortcut which +does a lot of the underlying work like creating a +:class:`~libs.prompt_toolkit.interface.CommandLineInterface` instance for you. + +When is this not sufficient: + - When you want to have more complicated layouts (maybe with sidebars or + multiple toolbars. Or visibility of certain user interface controls + according to some conditions.) + - When you wish to have multiple input buffers. (If you would create an + editor like a Vi clone.) + - Something else that requires more customization than what is possible + with the parameters of `prompt`. + +In that case, study the code in this file and build your own +`CommandLineInterface` instance. It's not too complicated. +""" +from __future__ import unicode_literals + +from .buffer import Buffer, AcceptAction +from .document import Document +from .enums import DEFAULT_BUFFER, SEARCH_BUFFER, EditingMode +from .filters import IsDone, HasFocus, RendererHeightIsKnown, to_simple_filter, to_cli_filter, Condition +from .history import InMemoryHistory +from .interface import CommandLineInterface, Application, AbortAction +from .key_binding.defaults import load_key_bindings_for_prompt +from .key_binding.registry import Registry +from .keys import Keys +from .layout import Window, HSplit, FloatContainer, Float +from .layout.containers import ConditionalContainer +from .layout.controls import BufferControl, TokenListControl +from .layout.dimension import LayoutDimension +from .layout.lexers import PygmentsLexer +from .layout.margins import PromptMargin, ConditionalMargin +from .layout.menus import CompletionsMenu, MultiColumnCompletionsMenu +from .layout.processors import PasswordProcessor, ConditionalProcessor, AppendAutoSuggestion, HighlightSearchProcessor, HighlightSelectionProcessor, DisplayMultipleCursors +from .layout.prompt import DefaultPrompt +from .layout.screen import Char +from .layout.toolbars import ValidationToolbar, SystemToolbar, ArgToolbar, SearchToolbar +from .layout.utils import explode_tokens +from .renderer import print_tokens as renderer_print_tokens +from .styles import DEFAULT_STYLE, Style, style_from_dict +from .token import Token +from .utils import is_conemu_ansi, is_windows, DummyContext + +from six import text_type, exec_, PY2 + +import os +import sys +import textwrap +import threading +import time + +try: + from pygments.lexer import Lexer as pygments_Lexer + from pygments.style import Style as pygments_Style +except ImportError: + pygments_Lexer = None + pygments_Style = None + +if is_windows(): + from .terminal.win32_output import Win32Output + from .terminal.conemu_output import ConEmuOutput +else: + from .terminal.vt100_output import Vt100_Output + + +__all__ = ( + 'create_eventloop', + 'create_output', + 'create_prompt_layout', + 'create_prompt_application', + 'prompt', + 'prompt_async', + 'create_confirm_application', + 'run_application', + 'confirm', + 'print_tokens', + 'clear', +) + + +def create_eventloop(inputhook=None, recognize_win32_paste=True): + """ + Create and return an + :class:`~libs.prompt_toolkit.eventloop.base.EventLoop` instance for a + :class:`~libs.prompt_toolkit.interface.CommandLineInterface`. + """ + if is_windows(): + from libs.prompt_toolkit.eventloop.win32 import Win32EventLoop as Loop + return Loop(inputhook=inputhook, recognize_paste=recognize_win32_paste) + else: + from libs.prompt_toolkit.eventloop.posix import PosixEventLoop as Loop + return Loop(inputhook=inputhook) + + +def create_output(stdout=None, true_color=False, ansi_colors_only=None): + """ + Return an :class:`~libs.prompt_toolkit.output.Output` instance for the command + line. + + :param true_color: When True, use 24bit colors instead of 256 colors. + (`bool` or :class:`~libs.prompt_toolkit.filters.SimpleFilter`.) + :param ansi_colors_only: When True, restrict to 16 ANSI colors only. + (`bool` or :class:`~libs.prompt_toolkit.filters.SimpleFilter`.) + """ + stdout = stdout or sys.__stdout__ + true_color = to_simple_filter(true_color) + + if is_windows(): + if is_conemu_ansi(): + return ConEmuOutput(stdout) + else: + return Win32Output(stdout) + else: + term = os.environ.get('TERM', '') + if PY2: + term = term.decode('utf-8') + + return Vt100_Output.from_pty( + stdout, true_color=true_color, + ansi_colors_only=ansi_colors_only, term=term) + + +def create_asyncio_eventloop(loop=None): + """ + Returns an asyncio :class:`~libs.prompt_toolkit.eventloop.EventLoop` instance + for usage in a :class:`~libs.prompt_toolkit.interface.CommandLineInterface`. It + is a wrapper around an asyncio loop. + + :param loop: The asyncio eventloop (or `None` if the default asyncioloop + should be used.) + """ + # Inline import, to make sure the rest doesn't break on Python 2. (Where + # asyncio is not available.) + if is_windows(): + from libs.prompt_toolkit.eventloop.asyncio_win32 import Win32AsyncioEventLoop as AsyncioEventLoop + else: + from libs.prompt_toolkit.eventloop.asyncio_posix import PosixAsyncioEventLoop as AsyncioEventLoop + + return AsyncioEventLoop(loop) + + +def _split_multiline_prompt(get_prompt_tokens): + """ + Take a `get_prompt_tokens` function and return three new functions instead. + One that tells whether this prompt consists of multiple lines; one that + returns the tokens to be shown on the lines above the input; and another + one with the tokens to be shown at the first line of the input. + """ + def has_before_tokens(cli): + for token, char in get_prompt_tokens(cli): + if '\n' in char: + return True + return False + + def before(cli): + result = [] + found_nl = False + for token, char in reversed(explode_tokens(get_prompt_tokens(cli))): + if found_nl: + result.insert(0, (token, char)) + elif char == '\n': + found_nl = True + return result + + def first_input_line(cli): + result = [] + for token, char in reversed(explode_tokens(get_prompt_tokens(cli))): + if char == '\n': + break + else: + result.insert(0, (token, char)) + return result + + return has_before_tokens, before, first_input_line + + +class _RPrompt(Window): + " The prompt that is displayed on the right side of the Window. " + def __init__(self, get_tokens=None): + get_tokens = get_tokens or (lambda cli: []) + + super(_RPrompt, self).__init__( + TokenListControl(get_tokens, align_right=True)) + + +def create_prompt_layout(message='', lexer=None, is_password=False, + reserve_space_for_menu=8, + get_prompt_tokens=None, get_continuation_tokens=None, + get_rprompt_tokens=None, + get_bottom_toolbar_tokens=None, + display_completions_in_columns=False, + extra_input_processors=None, multiline=False, + wrap_lines=True): + """ + Create a :class:`.Container` instance for a prompt. + + :param message: Text to be used as prompt. + :param lexer: :class:`~libs.prompt_toolkit.layout.lexers.Lexer` to be used for + the highlighting. + :param is_password: `bool` or :class:`~libs.prompt_toolkit.filters.CLIFilter`. + When True, display input as '*'. + :param reserve_space_for_menu: Space to be reserved for the menu. When >0, + make sure that a minimal height is allocated in the terminal, in order + to display the completion menu. + :param get_prompt_tokens: An optional callable that returns the tokens to be + shown in the menu. (To be used instead of a `message`.) + :param get_continuation_tokens: An optional callable that takes a + CommandLineInterface and width as input and returns a list of (Token, + text) tuples to be used for the continuation. + :param get_bottom_toolbar_tokens: An optional callable that returns the + tokens for a toolbar at the bottom. + :param display_completions_in_columns: `bool` or + :class:`~libs.prompt_toolkit.filters.CLIFilter`. Display the completions in + multiple columns. + :param multiline: `bool` or :class:`~libs.prompt_toolkit.filters.CLIFilter`. + When True, prefer a layout that is more adapted for multiline input. + Text after newlines is automatically indented, and search/arg input is + shown below the input, instead of replacing the prompt. + :param wrap_lines: `bool` or :class:`~libs.prompt_toolkit.filters.CLIFilter`. + When True (the default), automatically wrap long lines instead of + scrolling horizontally. + """ + assert isinstance(message, text_type), 'Please provide a unicode string.' + assert get_bottom_toolbar_tokens is None or callable(get_bottom_toolbar_tokens) + assert get_prompt_tokens is None or callable(get_prompt_tokens) + assert get_rprompt_tokens is None or callable(get_rprompt_tokens) + assert not (message and get_prompt_tokens) + + display_completions_in_columns = to_cli_filter(display_completions_in_columns) + multiline = to_cli_filter(multiline) + + if get_prompt_tokens is None: + get_prompt_tokens = lambda _: [(Token.Prompt, message)] + + has_before_tokens, get_prompt_tokens_1, get_prompt_tokens_2 = \ + _split_multiline_prompt(get_prompt_tokens) + + # `lexer` is supposed to be a `Lexer` instance. But if a Pygments lexer + # class is given, turn it into a PygmentsLexer. (Important for + # backwards-compatibility.) + try: + if pygments_Lexer and issubclass(lexer, pygments_Lexer): + lexer = PygmentsLexer(lexer, sync_from_start=True) + except TypeError: # Happens when lexer is `None` or an instance of something else. + pass + + # Create processors list. + input_processors = [ + ConditionalProcessor( + # By default, only highlight search when the search + # input has the focus. (Note that this doesn't mean + # there is no search: the Vi 'n' binding for instance + # still allows to jump to the next match in + # navigation mode.) + HighlightSearchProcessor(preview_search=True), + HasFocus(SEARCH_BUFFER)), + HighlightSelectionProcessor(), + ConditionalProcessor(AppendAutoSuggestion(), HasFocus(DEFAULT_BUFFER) & ~IsDone()), + ConditionalProcessor(PasswordProcessor(), is_password), + DisplayMultipleCursors(DEFAULT_BUFFER), + ] + + if extra_input_processors: + input_processors.extend(extra_input_processors) + + # Show the prompt before the input (using the DefaultPrompt processor. + # This also replaces it with reverse-i-search and 'arg' when required. + # (Only for single line mode.) + # (DefaultPrompt should always be at the end of the processors.) + input_processors.append(ConditionalProcessor( + DefaultPrompt(get_prompt_tokens_2), ~multiline)) + + # Create bottom toolbar. + if get_bottom_toolbar_tokens: + toolbars = [ConditionalContainer( + Window(TokenListControl(get_bottom_toolbar_tokens, + default_char=Char(' ', Token.Toolbar)), + height=LayoutDimension.exact(1)), + filter=~IsDone() & RendererHeightIsKnown())] + else: + toolbars = [] + + def get_height(cli): + # If there is an autocompletion menu to be shown, make sure that our + # layout has at least a minimal height in order to display it. + if reserve_space_for_menu and not cli.is_done: + buff = cli.current_buffer + + # Reserve the space, either when there are completions, or when + # `complete_while_typing` is true and we expect completions very + # soon. + if buff.complete_while_typing() or buff.complete_state is not None: + return LayoutDimension(min=reserve_space_for_menu) + + return LayoutDimension() + + # Create and return Container instance. + return HSplit([ + # The main input, with completion menus floating on top of it. + FloatContainer( + HSplit([ + ConditionalContainer( + Window( + TokenListControl(get_prompt_tokens_1), + dont_extend_height=True), + Condition(has_before_tokens) + ), + Window( + BufferControl( + input_processors=input_processors, + lexer=lexer, + # Enable preview_search, we want to have immediate feedback + # in reverse-i-search mode. + preview_search=True), + get_height=get_height, + left_margins=[ + # In multiline mode, use the window margin to display + # the prompt and continuation tokens. + ConditionalMargin( + PromptMargin(get_prompt_tokens_2, get_continuation_tokens), + filter=multiline + ) + ], + wrap_lines=wrap_lines, + ), + ]), + [ + # Completion menus. + Float(xcursor=True, + ycursor=True, + content=CompletionsMenu( + max_height=16, + scroll_offset=1, + extra_filter=HasFocus(DEFAULT_BUFFER) & + ~display_completions_in_columns)), + Float(xcursor=True, + ycursor=True, + content=MultiColumnCompletionsMenu( + extra_filter=HasFocus(DEFAULT_BUFFER) & + display_completions_in_columns, + show_meta=True)), + + # The right prompt. + Float(right=0, top=0, hide_when_covering_content=True, + content=_RPrompt(get_rprompt_tokens)), + ] + ), + ValidationToolbar(), + SystemToolbar(), + + # In multiline mode, we use two toolbars for 'arg' and 'search'. + ConditionalContainer(ArgToolbar(), multiline), + ConditionalContainer(SearchToolbar(), multiline), + ] + toolbars) + + +def create_prompt_application( + message='', + multiline=False, + wrap_lines=True, + is_password=False, + vi_mode=False, + editing_mode=EditingMode.EMACS, + complete_while_typing=True, + enable_history_search=False, + lexer=None, + enable_system_bindings=False, + enable_open_in_editor=False, + validator=None, + completer=None, + reserve_space_for_menu=8, + auto_suggest=None, + style=None, + history=None, + clipboard=None, + get_prompt_tokens=None, + get_continuation_tokens=None, + get_rprompt_tokens=None, + get_bottom_toolbar_tokens=None, + display_completions_in_columns=False, + get_title=None, + mouse_support=False, + extra_input_processors=None, + key_bindings_registry=None, + on_abort=AbortAction.RAISE_EXCEPTION, + on_exit=AbortAction.RAISE_EXCEPTION, + accept_action=AcceptAction.RETURN_DOCUMENT, + erase_when_done=False, + default=''): + """ + Create an :class:`~Application` instance for a prompt. + + (It is meant to cover 90% of the prompt use cases, where no extreme + customization is required. For more complex input, it is required to create + a custom :class:`~Application` instance.) + + :param message: Text to be shown before the prompt. + :param mulitiline: Allow multiline input. Pressing enter will insert a + newline. (This requires Meta+Enter to accept the input.) + :param wrap_lines: `bool` or :class:`~libs.prompt_toolkit.filters.CLIFilter`. + When True (the default), automatically wrap long lines instead of + scrolling horizontally. + :param is_password: Show asterisks instead of the actual typed characters. + :param editing_mode: ``EditingMode.VI`` or ``EditingMode.EMACS``. + :param vi_mode: `bool`, if True, Identical to ``editing_mode=EditingMode.VI``. + :param complete_while_typing: `bool` or + :class:`~libs.prompt_toolkit.filters.SimpleFilter`. Enable autocompletion + while typing. + :param enable_history_search: `bool` or + :class:`~libs.prompt_toolkit.filters.SimpleFilter`. Enable up-arrow parting + string matching. + :param lexer: :class:`~libs.prompt_toolkit.layout.lexers.Lexer` to be used for + the syntax highlighting. + :param validator: :class:`~libs.prompt_toolkit.validation.Validator` instance + for input validation. + :param completer: :class:`~libs.prompt_toolkit.completion.Completer` instance + for input completion. + :param reserve_space_for_menu: Space to be reserved for displaying the menu. + (0 means that no space needs to be reserved.) + :param auto_suggest: :class:`~libs.prompt_toolkit.auto_suggest.AutoSuggest` + instance for input suggestions. + :param style: :class:`.Style` instance for the color scheme. + :param enable_system_bindings: `bool` or + :class:`~libs.prompt_toolkit.filters.CLIFilter`. Pressing Meta+'!' will show + a system prompt. + :param enable_open_in_editor: `bool` or + :class:`~libs.prompt_toolkit.filters.CLIFilter`. Pressing 'v' in Vi mode or + C-X C-E in emacs mode will open an external editor. + :param history: :class:`~libs.prompt_toolkit.history.History` instance. + :param clipboard: :class:`~libs.prompt_toolkit.clipboard.base.Clipboard` instance. + (e.g. :class:`~libs.prompt_toolkit.clipboard.in_memory.InMemoryClipboard`) + :param get_bottom_toolbar_tokens: Optional callable which takes a + :class:`~libs.prompt_toolkit.interface.CommandLineInterface` and returns a + list of tokens for the bottom toolbar. + :param display_completions_in_columns: `bool` or + :class:`~libs.prompt_toolkit.filters.CLIFilter`. Display the completions in + multiple columns. + :param get_title: Callable that returns the title to be displayed in the + terminal. + :param mouse_support: `bool` or :class:`~libs.prompt_toolkit.filters.CLIFilter` + to enable mouse support. + :param default: The default text to be shown in the input buffer. (This can + be edited by the user.) + """ + if key_bindings_registry is None: + key_bindings_registry = load_key_bindings_for_prompt( + enable_system_bindings=enable_system_bindings, + enable_open_in_editor=enable_open_in_editor) + + # Ensure backwards-compatibility, when `vi_mode` is passed. + if vi_mode: + editing_mode = EditingMode.VI + + # Make sure that complete_while_typing is disabled when enable_history_search + # is enabled. (First convert to SimpleFilter, to avoid doing bitwise operations + # on bool objects.) + complete_while_typing = to_simple_filter(complete_while_typing) + enable_history_search = to_simple_filter(enable_history_search) + multiline = to_simple_filter(multiline) + + complete_while_typing = complete_while_typing & ~enable_history_search + + # Accept Pygments styles as well for backwards compatibility. + try: + if pygments_Style and issubclass(style, pygments_Style): + style = style_from_dict(style.styles) + except TypeError: # Happens when style is `None` or an instance of something else. + pass + + # Create application + return Application( + layout=create_prompt_layout( + message=message, + lexer=lexer, + is_password=is_password, + reserve_space_for_menu=(reserve_space_for_menu if completer is not None else 0), + multiline=Condition(lambda cli: multiline()), + get_prompt_tokens=get_prompt_tokens, + get_continuation_tokens=get_continuation_tokens, + get_rprompt_tokens=get_rprompt_tokens, + get_bottom_toolbar_tokens=get_bottom_toolbar_tokens, + display_completions_in_columns=display_completions_in_columns, + extra_input_processors=extra_input_processors, + wrap_lines=wrap_lines), + buffer=Buffer( + enable_history_search=enable_history_search, + complete_while_typing=complete_while_typing, + is_multiline=multiline, + history=(history or InMemoryHistory()), + validator=validator, + completer=completer, + auto_suggest=auto_suggest, + accept_action=accept_action, + initial_document=Document(default), + ), + style=style or DEFAULT_STYLE, + clipboard=clipboard, + key_bindings_registry=key_bindings_registry, + get_title=get_title, + mouse_support=mouse_support, + editing_mode=editing_mode, + erase_when_done=erase_when_done, + reverse_vi_search_direction=True, + on_abort=on_abort, + on_exit=on_exit) + + +def prompt(message='', **kwargs): + """ + Get input from the user and return it. + + This is a wrapper around a lot of ``libs.prompt_toolkit`` functionality and can + be a replacement for `raw_input`. (or GNU readline.) + + If you want to keep your history across several calls, create one + :class:`~libs.prompt_toolkit.history.History` instance and pass it every time. + + This function accepts many keyword arguments. Except for the following, + they are a proxy to the arguments of :func:`.create_prompt_application`. + + :param patch_stdout: Replace ``sys.stdout`` by a proxy that ensures that + print statements from other threads won't destroy the prompt. (They + will be printed above the prompt instead.) + :param return_asyncio_coroutine: When True, return a asyncio coroutine. (Python >3.3) + :param true_color: When True, use 24bit colors instead of 256 colors. + :param refresh_interval: (number; in seconds) When given, refresh the UI + every so many seconds. + """ + patch_stdout = kwargs.pop('patch_stdout', False) + return_asyncio_coroutine = kwargs.pop('return_asyncio_coroutine', False) + true_color = kwargs.pop('true_color', False) + refresh_interval = kwargs.pop('refresh_interval', 0) + eventloop = kwargs.pop('eventloop', None) + + application = create_prompt_application(message, **kwargs) + + return run_application(application, + patch_stdout=patch_stdout, + return_asyncio_coroutine=return_asyncio_coroutine, + true_color=true_color, + refresh_interval=refresh_interval, + eventloop=eventloop) + + +def run_application( + application, patch_stdout=False, return_asyncio_coroutine=False, + true_color=False, refresh_interval=0, eventloop=None): + """ + Run a prompt toolkit application. + + :param patch_stdout: Replace ``sys.stdout`` by a proxy that ensures that + print statements from other threads won't destroy the prompt. (They + will be printed above the prompt instead.) + :param return_asyncio_coroutine: When True, return a asyncio coroutine. (Python >3.3) + :param true_color: When True, use 24bit colors instead of 256 colors. + :param refresh_interval: (number; in seconds) When given, refresh the UI + every so many seconds. + """ + assert isinstance(application, Application) + + if return_asyncio_coroutine: + eventloop = create_asyncio_eventloop() + else: + eventloop = eventloop or create_eventloop() + + # Create CommandLineInterface. + cli = CommandLineInterface( + application=application, + eventloop=eventloop, + output=create_output(true_color=true_color)) + + # Set up refresh interval. + if refresh_interval: + done = [False] + def start_refresh_loop(cli): + def run(): + while not done[0]: + time.sleep(refresh_interval) + cli.request_redraw() + t = threading.Thread(target=run) + t.daemon = True + t.start() + + def stop_refresh_loop(cli): + done[0] = True + + cli.on_start += start_refresh_loop + cli.on_stop += stop_refresh_loop + + # Replace stdout. + patch_context = cli.patch_stdout_context(raw=True) if patch_stdout else DummyContext() + + # Read input and return it. + if return_asyncio_coroutine: + # Create an asyncio coroutine and call it. + exec_context = {'patch_context': patch_context, 'cli': cli, + 'Document': Document} + exec_(textwrap.dedent(''' + def prompt_coro(): + # Inline import, because it slows down startup when asyncio is not + # needed. + import asyncio + + @asyncio.coroutine + def run(): + with patch_context: + result = yield from cli.run_async() + + if isinstance(result, Document): # Backwards-compatibility. + return result.text + return result + return run() + '''), exec_context) + + return exec_context['prompt_coro']() + else: + try: + with patch_context: + result = cli.run() + + if isinstance(result, Document): # Backwards-compatibility. + return result.text + return result + finally: + eventloop.close() + + +def prompt_async(message='', **kwargs): + """ + Similar to :func:`.prompt`, but return an asyncio coroutine instead. + """ + kwargs['return_asyncio_coroutine'] = True + return prompt(message, **kwargs) + + +def create_confirm_application(message): + """ + Create a confirmation `Application` that returns True/False. + """ + registry = Registry() + + @registry.add_binding('y') + @registry.add_binding('Y') + def _(event): + event.cli.buffers[DEFAULT_BUFFER].text = 'y' + event.cli.set_return_value(True) + + @registry.add_binding('n') + @registry.add_binding('N') + @registry.add_binding(Keys.ControlC) + def _(event): + event.cli.buffers[DEFAULT_BUFFER].text = 'n' + event.cli.set_return_value(False) + + return create_prompt_application(message, key_bindings_registry=registry) + + +def confirm(message='Confirm (y or n) '): + """ + Display a confirmation prompt. + """ + assert isinstance(message, text_type) + + app = create_confirm_application(message) + return run_application(app) + + +def print_tokens(tokens, style=None, true_color=False, file=None): + """ + Print a list of (Token, text) tuples in the given style to the output. + E.g.:: + + style = style_from_dict({ + Token.Hello: '#ff0066', + Token.World: '#884444 italic', + }) + tokens = [ + (Token.Hello, 'Hello'), + (Token.World, 'World'), + ] + print_tokens(tokens, style=style) + + :param tokens: List of ``(Token, text)`` tuples. + :param style: :class:`.Style` instance for the color scheme. + :param true_color: When True, use 24bit colors instead of 256 colors. + :param file: The output file. This can be `sys.stdout` or `sys.stderr`. + """ + if style is None: + style = DEFAULT_STYLE + assert isinstance(style, Style) + + output = create_output(true_color=true_color, stdout=file) + renderer_print_tokens(output, tokens, style) + + +def clear(): + """ + Clear the screen. + """ + out = create_output() + out.erase_screen() + out.cursor_goto(0, 0) + out.flush() + + +# Deprecated alias for `prompt`. +get_input = prompt +# Deprecated alias for create_prompt_layout +create_default_layout = create_prompt_layout +# Deprecated alias for create_prompt_application +create_default_application = create_prompt_application diff --git a/src/libs/prompt_toolkit/styles/__init__.py b/src/libs/prompt_toolkit/styles/__init__.py new file mode 100644 index 0000000..5e10f34 --- /dev/null +++ b/src/libs/prompt_toolkit/styles/__init__.py @@ -0,0 +1,21 @@ +""" +Styling for libs.prompt_toolkit applications. +""" +from __future__ import unicode_literals + +from .base import * +from .defaults import * +from .from_dict import * +from .from_pygments import * +from .utils import * + + +#: The default built-in style. +#: (For backwards compatibility, when Pygments is installed, this includes the +#: default Pygments style.) +try: + import pygments +except ImportError: + DEFAULT_STYLE = style_from_dict(DEFAULT_STYLE_EXTENSIONS) +else: + DEFAULT_STYLE = style_from_pygments() diff --git a/src/libs/prompt_toolkit/styles/base.py b/src/libs/prompt_toolkit/styles/base.py new file mode 100644 index 0000000..031e113 --- /dev/null +++ b/src/libs/prompt_toolkit/styles/base.py @@ -0,0 +1,86 @@ +""" +The base classes for the styling. +""" +from __future__ import unicode_literals +from abc import ABCMeta, abstractmethod +from collections import namedtuple +from six import with_metaclass + +__all__ = ( + 'Attrs', + 'DEFAULT_ATTRS', + 'ANSI_COLOR_NAMES', + 'Style', + 'DynamicStyle', +) + + +#: Style attributes. +Attrs = namedtuple('Attrs', 'color bgcolor bold underline italic blink reverse') +""" +:param color: Hexadecimal string. E.g. '000000' or Ansi color name: e.g. 'ansiblue' +:param bgcolor: Hexadecimal string. E.g. 'ffffff' or Ansi color name: e.g. 'ansired' +:param bold: Boolean +:param underline: Boolean +:param italic: Boolean +:param blink: Boolean +:param reverse: Boolean +""" + +#: The default `Attrs`. +DEFAULT_ATTRS = Attrs(color=None, bgcolor=None, bold=False, underline=False, + italic=False, blink=False, reverse=False) + + +#: ``Attrs.bgcolor/fgcolor`` can be in either 'ffffff' format, or can be any of +#: the following in case we want to take colors from the 8/16 color palette. +#: Usually, in that case, the terminal application allows to configure the RGB +#: values for these names. +ANSI_COLOR_NAMES = [ + 'ansiblack', 'ansiwhite', 'ansidefault', + + # Low intensity. + 'ansired', 'ansigreen', 'ansiyellow', 'ansiblue', 'ansifuchsia', 'ansiturquoise', 'ansilightgray', + + # High intensity. (Not supported everywhere.) + 'ansidarkgray', 'ansidarkred', 'ansidarkgreen', 'ansibrown', 'ansidarkblue', + 'ansipurple', 'ansiteal', +] + + +class Style(with_metaclass(ABCMeta, object)): + """ + Abstract base class for libs.prompt_toolkit styles. + """ + @abstractmethod + def get_attrs_for_token(self, token): + """ + Return :class:`.Attrs` for the given token. + """ + + @abstractmethod + def invalidation_hash(self): + """ + Invalidation hash for the style. When this changes over time, the + renderer knows that something in the style changed, and that everything + has to be redrawn. + """ + + +class DynamicStyle(Style): + """ + Style class that can dynamically returns an other Style. + + :param get_style: Callable that returns a :class:`.Style` instance. + """ + def __init__(self, get_style): + self.get_style = get_style + + def get_attrs_for_token(self, token): + style = self.get_style() + assert isinstance(style, Style) + + return style.get_attrs_for_token(token) + + def invalidation_hash(self): + return self.get_style().invalidation_hash() diff --git a/src/libs/prompt_toolkit/styles/defaults.py b/src/libs/prompt_toolkit/styles/defaults.py new file mode 100644 index 0000000..c1a299e --- /dev/null +++ b/src/libs/prompt_toolkit/styles/defaults.py @@ -0,0 +1,95 @@ +""" +The default styling. +""" +from __future__ import unicode_literals + +from libs.prompt_toolkit.token import Token + +__all__ = ( + 'DEFAULT_STYLE_EXTENSIONS', + 'default_style_extensions', +) + + +#: Styling of prompt-toolkit specific tokens, that are not know by the default +#: Pygments style. +DEFAULT_STYLE_EXTENSIONS = { + # Highlighting of search matches in document. + Token.SearchMatch: 'noinherit reverse', + Token.SearchMatch.Current: 'noinherit #ffffff bg:#448844 underline', + + # Highlighting of select text in document. + Token.SelectedText: 'reverse', + + Token.CursorColumn: 'bg:#dddddd', + Token.CursorLine: 'underline', + Token.ColorColumn: 'bg:#ccaacc', + + # Highlighting of matching brackets. + Token.MatchingBracket: '', + Token.MatchingBracket.Other: '#000000 bg:#aacccc', + Token.MatchingBracket.Cursor: '#ff8888 bg:#880000', + + Token.MultipleCursors.Cursor: '#000000 bg:#ccccaa', + + # Line numbers. + Token.LineNumber: '#888888', + Token.LineNumber.Current: 'bold', + Token.Tilde: '#8888ff', + + # Default prompt. + Token.Prompt: '', + Token.Prompt.Arg: 'noinherit', + Token.Prompt.Search: 'noinherit', + Token.Prompt.Search.Text: '', + + # Search toolbar. + Token.Toolbar.Search: 'bold', + Token.Toolbar.Search.Text: 'nobold', + + # System toolbar + Token.Toolbar.System: 'bold', + Token.Toolbar.System.Text: 'nobold', + + # "arg" toolbar. + Token.Toolbar.Arg: 'bold', + Token.Toolbar.Arg.Text: 'nobold', + + # Validation toolbar. + Token.Toolbar.Validation: 'bg:#550000 #ffffff', + Token.WindowTooSmall: 'bg:#550000 #ffffff', + + # Completions toolbar. + Token.Toolbar.Completions: 'bg:#bbbbbb #000000', + Token.Toolbar.Completions.Arrow: 'bg:#bbbbbb #000000 bold', + Token.Toolbar.Completions.Completion: 'bg:#bbbbbb #000000', + Token.Toolbar.Completions.Completion.Current: 'bg:#444444 #ffffff', + + # Completions menu. + Token.Menu.Completions: 'bg:#bbbbbb #000000', + Token.Menu.Completions.Completion: '', + Token.Menu.Completions.Completion.Current: 'bg:#888888 #ffffff', + Token.Menu.Completions.Meta: 'bg:#999999 #000000', + Token.Menu.Completions.Meta.Current: 'bg:#aaaaaa #000000', + Token.Menu.Completions.MultiColumnMeta: 'bg:#aaaaaa #000000', + + # Scrollbars. + Token.Scrollbar: 'bg:#888888', + Token.Scrollbar.Button: 'bg:#444444', + Token.Scrollbar.Arrow: 'bg:#222222 #888888 bold', + + # Auto suggestion text. + Token.AutoSuggestion: '#666666', + + # Trailing whitespace and tabs. + Token.TrailingWhiteSpace: '#999999', + Token.Tab: '#999999', + + # When Control-C has been pressed. Grayed. + Token.Aborted: '#888888', + + # Entering a Vi digraph. + Token.Digraph: '#4444ff', +} + +default_style_extensions = DEFAULT_STYLE_EXTENSIONS # Old name. diff --git a/src/libs/prompt_toolkit/styles/from_dict.py b/src/libs/prompt_toolkit/styles/from_dict.py new file mode 100644 index 0000000..8dcd13e --- /dev/null +++ b/src/libs/prompt_toolkit/styles/from_dict.py @@ -0,0 +1,148 @@ +""" +Tool for creating styles from a dictionary. + +This is very similar to the Pygments style dictionary, with some additions: +- Support for reverse and blink. +- Support for ANSI color names. (These will map directly to the 16 terminal + colors.) +""" +from collections.abc import Mapping + +from .base import Style, DEFAULT_ATTRS, ANSI_COLOR_NAMES +from .defaults import DEFAULT_STYLE_EXTENSIONS +from .utils import merge_attrs, split_token_in_parts +from six.moves import range + +__all__ = ( + 'style_from_dict', +) + + +def _colorformat(text): + """ + Parse/validate color format. + + Like in Pygments, but also support the ANSI color names. + (These will map to the colors of the 16 color palette.) + """ + if text[0:1] == '#': + col = text[1:] + if col in ANSI_COLOR_NAMES: + return col + elif len(col) == 6: + return col + elif len(col) == 3: + return col[0]*2 + col[1]*2 + col[2]*2 + elif text == '': + return text + + raise ValueError('Wrong color format %r' % text) + + +def style_from_dict(style_dict, include_defaults=True): + """ + Create a ``Style`` instance from a dictionary or other mapping. + + The dictionary is equivalent to the ``Style.styles`` dictionary from + pygments, with a few additions: it supports 'reverse' and 'blink'. + + Usage:: + + style_from_dict({ + Token: '#ff0000 bold underline', + Token.Title: 'blink', + Token.SomethingElse: 'reverse', + }) + + :param include_defaults: Include the defaults (built-in) styling for + selected text, etc...) + """ + assert isinstance(style_dict, Mapping) + + if include_defaults: + s2 = {} + s2.update(DEFAULT_STYLE_EXTENSIONS) + s2.update(style_dict) + style_dict = s2 + + # Expand token inheritance and turn style description into Attrs. + token_to_attrs = {} + + # (Loop through the tokens in order. Sorting makes sure that + # we process the parent first.) + for ttype, styledef in sorted(style_dict.items()): + # Start from parent Attrs or default Attrs. + attrs = DEFAULT_ATTRS + + if 'noinherit' not in styledef: + for i in range(1, len(ttype) + 1): + try: + attrs = token_to_attrs[ttype[:-i]] + except KeyError: + pass + else: + break + + # Now update with the given attributes. + for part in styledef.split(): + if part == 'noinherit': + pass + elif part == 'bold': + attrs = attrs._replace(bold=True) + elif part == 'nobold': + attrs = attrs._replace(bold=False) + elif part == 'italic': + attrs = attrs._replace(italic=True) + elif part == 'noitalic': + attrs = attrs._replace(italic=False) + elif part == 'underline': + attrs = attrs._replace(underline=True) + elif part == 'nounderline': + attrs = attrs._replace(underline=False) + + # libs.prompt_toolkit extensions. Not in Pygments. + elif part == 'blink': + attrs = attrs._replace(blink=True) + elif part == 'noblink': + attrs = attrs._replace(blink=False) + elif part == 'reverse': + attrs = attrs._replace(reverse=True) + elif part == 'noreverse': + attrs = attrs._replace(reverse=False) + + # Pygments properties that we ignore. + elif part in ('roman', 'sans', 'mono'): + pass + elif part.startswith('border:'): + pass + + # Colors. + + elif part.startswith('bg:'): + attrs = attrs._replace(bgcolor=_colorformat(part[3:])) + else: + attrs = attrs._replace(color=_colorformat(part)) + + token_to_attrs[ttype] = attrs + + return _StyleFromDict(token_to_attrs) + + +class _StyleFromDict(Style): + """ + Turn a dictionary that maps `Token` to `Attrs` into a style class. + + :param token_to_attrs: Dictionary that maps `Token` to `Attrs`. + """ + def __init__(self, token_to_attrs): + self.token_to_attrs = token_to_attrs + + def get_attrs_for_token(self, token): + # Split Token. + list_of_attrs = [] + for token in split_token_in_parts(token): + list_of_attrs.append(self.token_to_attrs.get(token, DEFAULT_ATTRS)) + return merge_attrs(list_of_attrs) + + def invalidation_hash(self): + return id(self.token_to_attrs) diff --git a/src/libs/prompt_toolkit/styles/from_pygments.py b/src/libs/prompt_toolkit/styles/from_pygments.py new file mode 100644 index 0000000..4abe75f --- /dev/null +++ b/src/libs/prompt_toolkit/styles/from_pygments.py @@ -0,0 +1,77 @@ +""" +Adaptor for building libs.prompt_toolkit styles, starting from a Pygments style. + +Usage:: + + from pygments.styles.tango import TangoStyle + style = style_from_pygments(pygments_style_cls=TangoStyle) +""" +from __future__ import unicode_literals + +from .base import Style +from .from_dict import style_from_dict + +__all__ = ( + 'PygmentsStyle', + 'style_from_pygments', +) + + +# Following imports are only needed when a ``PygmentsStyle`` class is used. +try: + from pygments.style import Style as pygments_Style + from pygments.styles.default import DefaultStyle as pygments_DefaultStyle +except ImportError: + pygments_Style = None + pygments_DefaultStyle = None + + +def style_from_pygments(style_cls=pygments_DefaultStyle, + style_dict=None, + include_defaults=True): + """ + Shortcut to create a :class:`.Style` instance from a Pygments style class + and a style dictionary. + + Example:: + + from libs.prompt_toolkit.styles.from_pygments import style_from_pygments + from pygments.styles import get_style_by_name + style = style_from_pygments(get_style_by_name('monokai')) + + :param style_cls: Pygments style class to start from. + :param style_dict: Dictionary for this style. `{Token: style}`. + :param include_defaults: (`bool`) Include libs.prompt_toolkit extensions. + """ + assert style_dict is None or isinstance(style_dict, dict) + assert style_cls is None or issubclass(style_cls, pygments_Style) + + styles_dict = {} + + if style_cls is not None: + styles_dict.update(style_cls.styles) + + if style_dict is not None: + styles_dict.update(style_dict) + + return style_from_dict(styles_dict, include_defaults=include_defaults) + + +class PygmentsStyle(Style): + " Deprecated. " + def __new__(cls, pygments_style_cls): + assert issubclass(pygments_style_cls, pygments_Style) + return style_from_dict(pygments_style_cls.styles) + + def invalidation_hash(self): + pass + + @classmethod + def from_defaults(cls, style_dict=None, + pygments_style_cls=pygments_DefaultStyle, + include_extensions=True): + " Deprecated. " + return style_from_pygments( + style_cls=pygments_style_cls, + style_dict=style_dict, + include_defaults=include_extensions) diff --git a/src/libs/prompt_toolkit/styles/utils.py b/src/libs/prompt_toolkit/styles/utils.py new file mode 100644 index 0000000..6087e76 --- /dev/null +++ b/src/libs/prompt_toolkit/styles/utils.py @@ -0,0 +1,45 @@ +from __future__ import unicode_literals +from .base import DEFAULT_ATTRS, Attrs + +__all__ = ( + 'split_token_in_parts', + 'merge_attrs', +) + + +def split_token_in_parts(token): + """ + Take a Token, and turn it in a list of tokens, by splitting + it on ':' (taking that as a separator.) + """ + result = [] + current = [] + for part in token + (':', ): + if part == ':': + if current: + result.append(tuple(current)) + current = [] + else: + current.append(part) + + return result + + +def merge_attrs(list_of_attrs): + """ + Take a list of :class:`.Attrs` instances and merge them into one. + Every `Attr` in the list can override the styling of the previous one. + """ + result = DEFAULT_ATTRS + + for attr in list_of_attrs: + result = Attrs( + color=attr.color or result.color, + bgcolor=attr.bgcolor or result.bgcolor, + bold=attr.bold or result.bold, + underline=attr.underline or result.underline, + italic=attr.italic or result.italic, + blink=attr.blink or result.blink, + reverse=attr.reverse or result.reverse) + + return result diff --git a/src/libs/prompt_toolkit/terminal/__init__.py b/src/libs/prompt_toolkit/terminal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/libs/prompt_toolkit/terminal/conemu_output.py b/src/libs/prompt_toolkit/terminal/conemu_output.py new file mode 100644 index 0000000..033aef2 --- /dev/null +++ b/src/libs/prompt_toolkit/terminal/conemu_output.py @@ -0,0 +1,42 @@ +from __future__ import unicode_literals + +from libs.prompt_toolkit.renderer import Output + +from .win32_output import Win32Output +from .vt100_output import Vt100_Output + +__all__ = ( + 'ConEmuOutput', +) + + +class ConEmuOutput(object): + """ + ConEmu (Windows) output abstraction. + + ConEmu is a Windows console application, but it also supports ANSI escape + sequences. This output class is actually a proxy to both `Win32Output` and + `Vt100_Output`. It uses `Win32Output` for console sizing and scrolling, but + all cursor movements and scrolling happens through the `Vt100_Output`. + + This way, we can have 256 colors in ConEmu and Cmder. Rendering will be + even a little faster as well. + + http://conemu.github.io/ + http://gooseberrycreative.com/cmder/ + """ + def __init__(self, stdout): + self.win32_output = Win32Output(stdout) + self.vt100_output = Vt100_Output(stdout, lambda: None) + + def __getattr__(self, name): + if name in ('get_size', 'get_rows_below_cursor_position', + 'enable_mouse_support', 'disable_mouse_support', + 'scroll_buffer_to_prompt', 'get_win32_screen_buffer_info', + 'enable_bracketed_paste', 'disable_bracketed_paste'): + return getattr(self.win32_output, name) + else: + return getattr(self.vt100_output, name) + + +Output.register(ConEmuOutput) diff --git a/src/libs/prompt_toolkit/terminal/vt100_input.py b/src/libs/prompt_toolkit/terminal/vt100_input.py new file mode 100644 index 0000000..6a56890 --- /dev/null +++ b/src/libs/prompt_toolkit/terminal/vt100_input.py @@ -0,0 +1,520 @@ +""" +Parser for VT100 input stream. +""" +from __future__ import unicode_literals + +import os +import re +import six +import termios +import tty + +from six.moves import range + +from ..keys import Keys +from ..key_binding.input_processor import KeyPress + +__all__ = ( + 'InputStream', + 'raw_mode', + 'cooked_mode', +) + +_DEBUG_RENDERER_INPUT = False +_DEBUG_RENDERER_INPUT_FILENAME = 'prompt-toolkit-render-input.log' + + +# Regex matching any CPR response +# (Note that we use '\Z' instead of '$', because '$' could include a trailing +# newline.) +_cpr_response_re = re.compile('^' + re.escape('\x1b[') + r'\d+;\d+R\Z') + +# Mouse events: +# Typical: "Esc[MaB*" Urxvt: "Esc[96;14;13M" and for Xterm SGR: "Esc[<64;85;12M" +_mouse_event_re = re.compile('^' + re.escape('\x1b[') + r'( 30: + exclude += ('ansilightgray', 'ansidarkgray', 'ansiwhite', 'ansiblack') + + # Take the closest color. + # (Thanks to Pygments for this part.) + distance = 257*257*3 # "infinity" (>distance from #000000 to #ffffff) + match = 'ansidefault' + + for name, (r2, g2, b2) in ANSI_COLORS_TO_RGB.items(): + if name != 'ansidefault' and name not in exclude: + d = (r - r2) ** 2 + (g - g2) ** 2 + (b - b2) ** 2 + + if d < distance: + match = name + distance = d + + return match + + +class _16ColorCache(dict): + """ + Cache which maps (r, g, b) tuples to 16 ansi colors. + + :param bg: Cache for background colors, instead of foreground. + """ + def __init__(self, bg=False): + assert isinstance(bg, bool) + self.bg = bg + + def get_code(self, value, exclude=()): + """ + Return a (ansi_code, ansi_name) tuple. (E.g. ``(44, 'ansiblue')``.) for + a given (r,g,b) value. + """ + key = (value, exclude) + if key not in self: + self[key] = self._get(value, exclude) + return self[key] + + def _get(self, value, exclude=()): + r, g, b = value + match = _get_closest_ansi_color(r, g, b, exclude=exclude) + + # Turn color name into code. + if self.bg: + code = BG_ANSI_COLORS[match] + else: + code = FG_ANSI_COLORS[match] + + self[value] = code + return code, match + + +class _256ColorCache(dict): + """ + Cach which maps (r, g, b) tuples to 256 colors. + """ + def __init__(self): + # Build color table. + colors = [] + + # colors 0..15: 16 basic colors + colors.append((0x00, 0x00, 0x00)) # 0 + colors.append((0xcd, 0x00, 0x00)) # 1 + colors.append((0x00, 0xcd, 0x00)) # 2 + colors.append((0xcd, 0xcd, 0x00)) # 3 + colors.append((0x00, 0x00, 0xee)) # 4 + colors.append((0xcd, 0x00, 0xcd)) # 5 + colors.append((0x00, 0xcd, 0xcd)) # 6 + colors.append((0xe5, 0xe5, 0xe5)) # 7 + colors.append((0x7f, 0x7f, 0x7f)) # 8 + colors.append((0xff, 0x00, 0x00)) # 9 + colors.append((0x00, 0xff, 0x00)) # 10 + colors.append((0xff, 0xff, 0x00)) # 11 + colors.append((0x5c, 0x5c, 0xff)) # 12 + colors.append((0xff, 0x00, 0xff)) # 13 + colors.append((0x00, 0xff, 0xff)) # 14 + colors.append((0xff, 0xff, 0xff)) # 15 + + # colors 16..232: the 6x6x6 color cube + valuerange = (0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff) + + for i in range(217): + r = valuerange[(i // 36) % 6] + g = valuerange[(i // 6) % 6] + b = valuerange[i % 6] + colors.append((r, g, b)) + + # colors 233..253: grayscale + for i in range(1, 22): + v = 8 + i * 10 + colors.append((v, v, v)) + + self.colors = colors + + def __missing__(self, value): + r, g, b = value + + # Find closest color. + # (Thanks to Pygments for this!) + distance = 257*257*3 # "infinity" (>distance from #000000 to #ffffff) + match = 0 + + for i, (r2, g2, b2) in enumerate(self.colors): + d = (r - r2) ** 2 + (g - g2) ** 2 + (b - b2) ** 2 + + if d < distance: + match = i + distance = d + + # Turn color name into code. + self[value] = match + return match + + +_16_fg_colors = _16ColorCache(bg=False) +_16_bg_colors = _16ColorCache(bg=True) +_256_colors = _256ColorCache() + + +class _EscapeCodeCache(dict): + """ + Cache for VT100 escape codes. It maps + (fgcolor, bgcolor, bold, underline, reverse) tuples to VT100 escape sequences. + + :param true_color: When True, use 24bit colors instead of 256 colors. + """ + def __init__(self, true_color=False, ansi_colors_only=False): + assert isinstance(true_color, bool) + self.true_color = true_color + self.ansi_colors_only = to_simple_filter(ansi_colors_only) + + def __missing__(self, attrs): + fgcolor, bgcolor, bold, underline, italic, blink, reverse = attrs + parts = [] + + parts.extend(self._colors_to_code(fgcolor, bgcolor)) + + if bold: + parts.append('1') + if italic: + parts.append('3') + if blink: + parts.append('5') + if underline: + parts.append('4') + if reverse: + parts.append('7') + + if parts: + result = '\x1b[0;' + ';'.join(parts) + 'm' + else: + result = '\x1b[0m' + + self[attrs] = result + return result + + def _color_name_to_rgb(self, color): + " Turn 'ffffff', into (0xff, 0xff, 0xff). " + try: + rgb = int(color, 16) + except ValueError: + raise + else: + r = (rgb >> 16) & 0xff + g = (rgb >> 8) & 0xff + b = rgb & 0xff + return r, g, b + + def _colors_to_code(self, fg_color, bg_color): + " Return a tuple with the vt100 values that represent this color. " + # When requesting ANSI colors only, and both fg/bg color were converted + # to ANSI, ensure that the foreground and background color are not the + # same. (Unless they were explicitely defined to be the same color.) + fg_ansi = [()] + + def get(color, bg): + table = BG_ANSI_COLORS if bg else FG_ANSI_COLORS + + if color is None: + return () + + # 16 ANSI colors. (Given by name.) + elif color in table: + return (table[color], ) + + # RGB colors. (Defined as 'ffffff'.) + else: + try: + rgb = self._color_name_to_rgb(color) + except ValueError: + return () + + # When only 16 colors are supported, use that. + if self.ansi_colors_only(): + if bg: # Background. + if fg_color != bg_color: + exclude = (fg_ansi[0], ) + else: + exclude = () + code, name = _16_bg_colors.get_code(rgb, exclude=exclude) + return (code, ) + else: # Foreground. + code, name = _16_fg_colors.get_code(rgb) + fg_ansi[0] = name + return (code, ) + + # True colors. (Only when this feature is enabled.) + elif self.true_color: + r, g, b = rgb + return (48 if bg else 38, 2, r, g, b) + + # 256 RGB colors. + else: + return (48 if bg else 38, 5, _256_colors[rgb]) + + result = [] + result.extend(get(fg_color, False)) + result.extend(get(bg_color, True)) + + return map(six.text_type, result) + + +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. + """ + # Inline imports, because these modules are not available on Windows. + # (This file is used by ConEmuOutput, which is used on Windows.) + import fcntl + import termios + + # Buffer for the C call + buf = array.array(b'h' if six.PY2 else u'h', [0, 0, 0, 0]) + + # Do TIOCGWINSZ (Get) + # Note: We should not pass 'True' as a fourth parameter to 'ioctl'. (True + # is the default.) This causes segmentation faults on some systems. + # See: https://github.com/jonathanslenders/python-prompt-toolkit/pull/364 + fcntl.ioctl(fileno, termios.TIOCGWINSZ, buf) + + # Return rows, cols + return buf[0], buf[1] + + +class Vt100_Output(Output): + """ + :param get_size: A callable which returns the `Size` of the output terminal. + :param stdout: Any object with has a `write` and `flush` method + an 'encoding' property. + :param true_color: Use 24bit color instead of 256 colors. (Can be a :class:`SimpleFilter`.) + When `ansi_colors_only` is set, only 16 colors are used. + :param ansi_colors_only: Restrict to 16 ANSI colors only. + :param term: The terminal environment variable. (xterm, xterm-256color, linux, ...) + :param write_binary: Encode the output before writing it. If `True` (the + default), the `stdout` object is supposed to expose an `encoding` attribute. + """ + def __init__(self, stdout, get_size, true_color=False, + ansi_colors_only=None, term=None, write_binary=True): + assert callable(get_size) + assert term is None or isinstance(term, six.text_type) + assert all(hasattr(stdout, a) for a in ('write', 'flush')) + + if write_binary: + assert hasattr(stdout, 'encoding') + + self._buffer = [] + self.stdout = stdout + self.write_binary = write_binary + self.get_size = get_size + self.true_color = to_simple_filter(true_color) + self.term = term or 'xterm' + + # ANSI colors only? + if ansi_colors_only is None: + # When not given, use the following default. + ANSI_COLORS_ONLY = bool(os.environ.get( + 'PROMPT_TOOLKIT_ANSI_COLORS_ONLY', False)) + + @Condition + def ansi_colors_only(): + return ANSI_COLORS_ONLY or term in ('linux', 'eterm-color') + else: + ansi_colors_only = to_simple_filter(ansi_colors_only) + + self.ansi_colors_only = ansi_colors_only + + # Cache for escape codes. + self._escape_code_cache = _EscapeCodeCache(ansi_colors_only=ansi_colors_only) + self._escape_code_cache_true_color = _EscapeCodeCache( + true_color=True, ansi_colors_only=ansi_colors_only) + + @classmethod + def from_pty(cls, stdout, true_color=False, ansi_colors_only=None, term=None): + """ + Create an Output class from a pseudo terminal. + (This will take the dimensions by reading the pseudo + terminal attributes.) + """ + assert stdout.isatty() + def get_size(): + rows, columns = _get_size(stdout.fileno()) + # If terminal (incorrectly) reports its size as 0, pick a reasonable default. + # See https://github.com/ipython/ipython/issues/10071 + return Size(rows=(rows or 24), columns=(columns or 80)) + + return cls(stdout, get_size, true_color=true_color, + ansi_colors_only=ansi_colors_only, term=term) + + def fileno(self): + " Return file descriptor. " + return self.stdout.fileno() + + def encoding(self): + " Return encoding used for stdout. " + return self.stdout.encoding + + def write_raw(self, data): + """ + Write raw data to output. + """ + self._buffer.append(data) + + def write(self, data): + """ + Write text to output. + (Removes vt100 escape codes. -- used for safely writing text.) + """ + self._buffer.append(data.replace('\x1b', '?')) + + def set_title(self, title): + """ + Set terminal title. + """ + if self.term not in ('linux', 'eterm-color'): # Not supported by the Linux console. + self.write_raw('\x1b]2;%s\x07' % title.replace('\x1b', '').replace('\x07', '')) + + def clear_title(self): + self.set_title('') + + def erase_screen(self): + """ + Erases the screen with the background colour and moves the cursor to + home. + """ + self.write_raw('\x1b[2J') + + def enter_alternate_screen(self): + self.write_raw('\x1b[?1049h\x1b[H') + + def quit_alternate_screen(self): + self.write_raw('\x1b[?1049l') + + def enable_mouse_support(self): + self.write_raw('\x1b[?1000h') + + # Enable urxvt Mouse mode. (For terminals that understand this.) + self.write_raw('\x1b[?1015h') + + # Also enable Xterm SGR mouse mode. (For terminals that understand this.) + self.write_raw('\x1b[?1006h') + + # Note: E.g. lxterminal understands 1000h, but not the urxvt or sgr + # extensions. + + def disable_mouse_support(self): + self.write_raw('\x1b[?1000l') + self.write_raw('\x1b[?1015l') + self.write_raw('\x1b[?1006l') + + def erase_end_of_line(self): + """ + Erases from the current cursor position to the end of the current line. + """ + self.write_raw('\x1b[K') + + def erase_down(self): + """ + Erases the screen from the current line down to the bottom of the + screen. + """ + self.write_raw('\x1b[J') + + def reset_attributes(self): + self.write_raw('\x1b[0m') + + def set_attributes(self, attrs): + """ + Create new style and output. + + :param attrs: `Attrs` instance. + """ + if self.true_color() and not self.ansi_colors_only(): + self.write_raw(self._escape_code_cache_true_color[attrs]) + else: + self.write_raw(self._escape_code_cache[attrs]) + + def disable_autowrap(self): + self.write_raw('\x1b[?7l') + + def enable_autowrap(self): + self.write_raw('\x1b[?7h') + + def enable_bracketed_paste(self): + self.write_raw('\x1b[?2004h') + + def disable_bracketed_paste(self): + self.write_raw('\x1b[?2004l') + + def cursor_goto(self, row=0, column=0): + """ Move cursor position. """ + self.write_raw('\x1b[%i;%iH' % (row, column)) + + def cursor_up(self, amount): + if amount == 0: + pass + elif amount == 1: + self.write_raw('\x1b[A') + else: + self.write_raw('\x1b[%iA' % amount) + + def cursor_down(self, amount): + if amount == 0: + pass + elif amount == 1: + # Note: Not the same as '\n', '\n' can cause the window content to + # scroll. + self.write_raw('\x1b[B') + else: + self.write_raw('\x1b[%iB' % amount) + + def cursor_forward(self, amount): + if amount == 0: + pass + elif amount == 1: + self.write_raw('\x1b[C') + else: + self.write_raw('\x1b[%iC' % amount) + + def cursor_backward(self, amount): + if amount == 0: + pass + elif amount == 1: + self.write_raw('\b') # '\x1b[D' + else: + self.write_raw('\x1b[%iD' % amount) + + def hide_cursor(self): + self.write_raw('\x1b[?25l') + + def show_cursor(self): + self.write_raw('\x1b[?12l\x1b[?25h') # Stop blinking cursor and show. + + def flush(self): + """ + Write to output stream and flush. + """ + if not self._buffer: + return + + data = ''.join(self._buffer) + + try: + # (We try to encode ourself, because that way we can replace + # characters that don't exist in the character set, avoiding + # UnicodeEncodeError crashes. E.g. u'\xb7' does not appear in 'ascii'.) + # My Arch Linux installation of july 2015 reported 'ANSI_X3.4-1968' + # for sys.stdout.encoding in xterm. + if self.write_binary: + if hasattr(self.stdout, 'buffer'): + out = self.stdout.buffer # Py3. + else: + out = self.stdout + out.write(data.encode(self.stdout.encoding or 'utf-8', 'replace')) + else: + self.stdout.write(data) + + self.stdout.flush() + except IOError as e: + if e.args and e.args[0] == errno.EINTR: + # Interrupted system call. Can happpen in case of a window + # resize signal. (Just ignore. The resize handler will render + # again anyway.) + pass + elif e.args and e.args[0] == 0: + # This can happen when there is a lot of output and the user + # sends a KeyboardInterrupt by pressing Control-C. E.g. in + # a Python REPL when we execute "while True: print('test')". + # (The `ptpython` REPL uses this `Output` class instead of + # `stdout` directly -- in order to be network transparent.) + # So, just ignore. + pass + else: + raise + + self._buffer = [] + + def ask_for_cpr(self): + """ + Asks for a cursor position report (CPR). + """ + self.write_raw('\x1b[6n') + self.flush() + + def bell(self): + " Sound bell. " + self.write_raw('\a') + self.flush() diff --git a/src/libs/prompt_toolkit/terminal/win32_input.py b/src/libs/prompt_toolkit/terminal/win32_input.py new file mode 100644 index 0000000..3bcc12b --- /dev/null +++ b/src/libs/prompt_toolkit/terminal/win32_input.py @@ -0,0 +1,364 @@ +from __future__ import unicode_literals +from ctypes import windll, pointer +from ctypes.wintypes import DWORD +from six.moves import range + +from libs.prompt_toolkit.key_binding.input_processor import KeyPress +from libs.prompt_toolkit.keys import Keys +from libs.prompt_toolkit.mouse_events import MouseEventType +from libs.prompt_toolkit.win32_types import EventTypes, KEY_EVENT_RECORD, MOUSE_EVENT_RECORD, INPUT_RECORD, STD_INPUT_HANDLE + +import msvcrt +import os +import sys +import six + +__all__ = ( + 'ConsoleInputReader', + 'raw_mode', + 'cooked_mode' +) + + +class ConsoleInputReader(object): + """ + :param recognize_paste: When True, try to discover paste actions and turn + the event into a BracketedPaste. + """ + # Keys with character data. + mappings = { + b'\x1b': Keys.Escape, + + b'\x00': Keys.ControlSpace, # Control-Space (Also for Ctrl-@) + b'\x01': Keys.ControlA, # Control-A (home) + b'\x02': Keys.ControlB, # Control-B (emacs cursor left) + b'\x03': Keys.ControlC, # Control-C (interrupt) + b'\x04': Keys.ControlD, # Control-D (exit) + b'\x05': Keys.ControlE, # Contrel-E (end) + b'\x06': Keys.ControlF, # Control-F (cursor forward) + b'\x07': Keys.ControlG, # Control-G + b'\x08': Keys.ControlH, # Control-H (8) (Identical to '\b') + b'\x09': Keys.ControlI, # Control-I (9) (Identical to '\t') + b'\x0a': Keys.ControlJ, # Control-J (10) (Identical to '\n') + b'\x0b': Keys.ControlK, # Control-K (delete until end of line; vertical tab) + b'\x0c': Keys.ControlL, # Control-L (clear; form feed) + b'\x0d': Keys.ControlJ, # Control-J NOTE: Windows sends \r instead of + # \n when pressing enter. We turn it into \n + # to be compatible with other platforms. + b'\x0e': Keys.ControlN, # Control-N (14) (history forward) + b'\x0f': Keys.ControlO, # Control-O (15) + b'\x10': Keys.ControlP, # Control-P (16) (history back) + b'\x11': Keys.ControlQ, # Control-Q + b'\x12': Keys.ControlR, # Control-R (18) (reverse search) + b'\x13': Keys.ControlS, # Control-S (19) (forward search) + b'\x14': Keys.ControlT, # Control-T + b'\x15': Keys.ControlU, # Control-U + b'\x16': Keys.ControlV, # Control-V + b'\x17': Keys.ControlW, # Control-W + b'\x18': Keys.ControlX, # Control-X + b'\x19': Keys.ControlY, # Control-Y (25) + b'\x1a': Keys.ControlZ, # Control-Z + + b'\x1c': Keys.ControlBackslash, # Both Control-\ and Ctrl-| + b'\x1d': Keys.ControlSquareClose, # Control-] + b'\x1e': Keys.ControlCircumflex, # Control-^ + b'\x1f': Keys.ControlUnderscore, # Control-underscore (Also for Ctrl-hypen.) + b'\x7f': Keys.Backspace, # (127) Backspace + } + + # Keys that don't carry character data. + keycodes = { + # Home/End + 33: Keys.PageUp, + 34: Keys.PageDown, + 35: Keys.End, + 36: Keys.Home, + + # Arrows + 37: Keys.Left, + 38: Keys.Up, + 39: Keys.Right, + 40: Keys.Down, + + 45: Keys.Insert, + 46: Keys.Delete, + + # F-keys. + 112: Keys.F1, + 113: Keys.F2, + 114: Keys.F3, + 115: Keys.F4, + 116: Keys.F5, + 117: Keys.F6, + 118: Keys.F7, + 119: Keys.F8, + 120: Keys.F9, + 121: Keys.F10, + 122: Keys.F11, + 123: Keys.F12, + } + + LEFT_ALT_PRESSED = 0x0002 + RIGHT_ALT_PRESSED = 0x0001 + SHIFT_PRESSED = 0x0010 + LEFT_CTRL_PRESSED = 0x0008 + RIGHT_CTRL_PRESSED = 0x0004 + + def __init__(self, recognize_paste=True): + self._fdcon = None + self.recognize_paste = recognize_paste + + # When stdin is a tty, use that handle, otherwise, create a handle from + # CONIN$. + if sys.stdin.isatty(): + self.handle = windll.kernel32.GetStdHandle(STD_INPUT_HANDLE) + else: + self._fdcon = os.open('CONIN$', os.O_RDWR | os.O_BINARY) + self.handle = msvcrt.get_osfhandle(self._fdcon) + + def close(self): + " Close fdcon. " + if self._fdcon is not None: + os.close(self._fdcon) + + def read(self): + """ + Return a list of `KeyPress` instances. It won't return anything when + there was nothing to read. (This function doesn't block.) + + http://msdn.microsoft.com/en-us/library/windows/desktop/ms684961(v=vs.85).aspx + """ + max_count = 2048 # Max events to read at the same time. + + read = DWORD(0) + arrtype = INPUT_RECORD * max_count + input_records = arrtype() + + # Get next batch of input event. + windll.kernel32.ReadConsoleInputW( + self.handle, pointer(input_records), max_count, pointer(read)) + + # First, get all the keys from the input buffer, in order to determine + # whether we should consider this a paste event or not. + all_keys = list(self._get_keys(read, input_records)) + + if self.recognize_paste and self._is_paste(all_keys): + gen = iter(all_keys) + for k in gen: + # Pasting: if the current key consists of text or \n, turn it + # into a BracketedPaste. + data = [] + while k and (isinstance(k.key, six.text_type) or + k.key == Keys.ControlJ): + data.append(k.data) + try: + k = next(gen) + except StopIteration: + k = None + + if data: + yield KeyPress(Keys.BracketedPaste, ''.join(data)) + if k is not None: + yield k + else: + for k in all_keys: + yield k + + def _get_keys(self, read, input_records): + """ + Generator that yields `KeyPress` objects from the input records. + """ + for i in range(read.value): + ir = input_records[i] + + # Get the right EventType from the EVENT_RECORD. + # (For some reason the Windows console application 'cmder' + # [http://gooseberrycreative.com/cmder/] can return '0' for + # ir.EventType. -- Just ignore that.) + if ir.EventType in EventTypes: + ev = getattr(ir.Event, EventTypes[ir.EventType]) + + # Process if this is a key event. (We also have mouse, menu and + # focus events.) + if type(ev) == KEY_EVENT_RECORD and ev.KeyDown: + for key_press in self._event_to_key_presses(ev): + yield key_press + + elif type(ev) == MOUSE_EVENT_RECORD: + for key_press in self._handle_mouse(ev): + yield key_press + + @staticmethod + def _is_paste(keys): + """ + Return `True` when we should consider this list of keys as a paste + event. Pasted text on windows will be turned into a + `Keys.BracketedPaste` event. (It's not 100% correct, but it is probably + the best possible way to detect pasting of text and handle that + correctly.) + """ + # Consider paste when it contains at least one newline and at least one + # other character. + text_count = 0 + newline_count = 0 + + for k in keys: + if isinstance(k.key, six.text_type): + text_count += 1 + if k.key == Keys.ControlJ: + newline_count += 1 + + return newline_count >= 1 and text_count > 1 + + def _event_to_key_presses(self, ev): + """ + For this `KEY_EVENT_RECORD`, return a list of `KeyPress` instances. + """ + assert type(ev) == KEY_EVENT_RECORD and ev.KeyDown + + result = None + + u_char = ev.uChar.UnicodeChar + ascii_char = u_char.encode('utf-8') + + # NOTE: We don't use `ev.uChar.AsciiChar`. That appears to be latin-1 + # encoded. See also: + # https://github.com/ipython/ipython/issues/10004 + # https://github.com/jonathanslenders/python-prompt-toolkit/issues/389 + + if u_char == '\x00': + if ev.VirtualKeyCode in self.keycodes: + result = KeyPress(self.keycodes[ev.VirtualKeyCode], '') + else: + if ascii_char in self.mappings: + if self.mappings[ascii_char] == Keys.ControlJ: + u_char = '\n' # Windows sends \n, turn into \r for unix compatibility. + result = KeyPress(self.mappings[ascii_char], u_char) + else: + result = KeyPress(u_char, u_char) + + # Correctly handle Control-Arrow keys. + if (ev.ControlKeyState & self.LEFT_CTRL_PRESSED or + ev.ControlKeyState & self.RIGHT_CTRL_PRESSED) and result: + if result.key == Keys.Left: + result.key = Keys.ControlLeft + + if result.key == Keys.Right: + result.key = Keys.ControlRight + + if result.key == Keys.Up: + result.key = Keys.ControlUp + + if result.key == Keys.Down: + result.key = Keys.ControlDown + + # Turn 'Tab' into 'BackTab' when shift was pressed. + if ev.ControlKeyState & self.SHIFT_PRESSED and result: + if result.key == Keys.Tab: + result.key = Keys.BackTab + + # Turn 'Space' into 'ControlSpace' when control was pressed. + if (ev.ControlKeyState & self.LEFT_CTRL_PRESSED or + ev.ControlKeyState & self.RIGHT_CTRL_PRESSED) and result and result.data == ' ': + result = KeyPress(Keys.ControlSpace, ' ') + + # Turn Control-Enter into META-Enter. (On a vt100 terminal, we cannot + # detect this combination. But it's really practical on Windows.) + if (ev.ControlKeyState & self.LEFT_CTRL_PRESSED or + ev.ControlKeyState & self.RIGHT_CTRL_PRESSED) and result and \ + result.key == Keys.ControlJ: + return [KeyPress(Keys.Escape, ''), result] + + # Return result. If alt was pressed, prefix the result with an + # 'Escape' key, just like unix VT100 terminals do. + + # NOTE: Only replace the left alt with escape. The right alt key often + # acts as altgr and is used in many non US keyboard layouts for + # typing some special characters, like a backslash. We don't want + # all backslashes to be prefixed with escape. (Esc-\ has a + # meaning in E-macs, for instance.) + if result: + meta_pressed = ev.ControlKeyState & self.LEFT_ALT_PRESSED + + if meta_pressed: + return [KeyPress(Keys.Escape, ''), result] + else: + return [result] + + else: + return [] + + def _handle_mouse(self, ev): + """ + Handle mouse events. Return a list of KeyPress instances. + """ + FROM_LEFT_1ST_BUTTON_PRESSED = 0x1 + + result = [] + + # Check event type. + if ev.ButtonState == FROM_LEFT_1ST_BUTTON_PRESSED: + # On a key press, generate both the mouse down and up event. + for event_type in [MouseEventType.MOUSE_DOWN, MouseEventType.MOUSE_UP]: + data = ';'.join([ + event_type, + str(ev.MousePosition.X), + str(ev.MousePosition.Y) + ]) + result.append(KeyPress(Keys.WindowsMouseEvent, data)) + + return result + + +class raw_mode(object): + """ + :: + + with raw_mode(stdin): + ''' the windows terminal is now in 'raw' mode. ''' + + The ``fileno`` attribute is ignored. This is to be compatble with the + `raw_input` method of `.vt100_input`. + """ + def __init__(self, fileno=None): + self.handle = windll.kernel32.GetStdHandle(STD_INPUT_HANDLE) + + def __enter__(self): + # Remember original mode. + original_mode = DWORD() + windll.kernel32.GetConsoleMode(self.handle, pointer(original_mode)) + self.original_mode = original_mode + + self._patch() + + def _patch(self): + # Set raw + ENABLE_ECHO_INPUT = 0x0004 + ENABLE_LINE_INPUT = 0x0002 + ENABLE_PROCESSED_INPUT = 0x0001 + + windll.kernel32.SetConsoleMode( + self.handle, self.original_mode.value & + ~(ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT)) + + def __exit__(self, *a, **kw): + # Restore original mode + windll.kernel32.SetConsoleMode(self.handle, self.original_mode) + + +class cooked_mode(raw_mode): + """ + :: + + with cooked_mode(stdin): + ''' the pseudo-terminal stdin is now used in raw mode ''' + """ + def _patch(self): + # Set cooked. + ENABLE_ECHO_INPUT = 0x0004 + ENABLE_LINE_INPUT = 0x0002 + ENABLE_PROCESSED_INPUT = 0x0001 + + windll.kernel32.SetConsoleMode( + self.handle, self.original_mode.value | + (ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT)) diff --git a/src/libs/prompt_toolkit/terminal/win32_output.py b/src/libs/prompt_toolkit/terminal/win32_output.py new file mode 100644 index 0000000..fde4967 --- /dev/null +++ b/src/libs/prompt_toolkit/terminal/win32_output.py @@ -0,0 +1,556 @@ +from __future__ import unicode_literals + +from ctypes import windll, byref, ArgumentError, c_char, c_long, c_ulong, c_uint, pointer +from ctypes.wintypes import DWORD + +from libs.prompt_toolkit.renderer import Output +from libs.prompt_toolkit.styles import ANSI_COLOR_NAMES +from libs.prompt_toolkit.win32_types import CONSOLE_SCREEN_BUFFER_INFO, STD_OUTPUT_HANDLE, STD_INPUT_HANDLE, COORD, SMALL_RECT + +import os +import six + +__all__ = ( + 'Win32Output', +) + + +def _coord_byval(coord): + """ + Turns a COORD object into a c_long. + This will cause it to be passed by value instead of by reference. (That is what I think at least.) + + When runing ``ptipython`` is run (only with IPython), we often got the following error:: + + Error in 'SetConsoleCursorPosition'. + ArgumentError("argument 2: : wrong type",) + argument 2: : wrong type + + It was solved by turning ``COORD`` parameters into a ``c_long`` like this. + + More info: http://msdn.microsoft.com/en-us/library/windows/desktop/ms686025(v=vs.85).aspx + """ + return c_long(coord.Y * 0x10000 | coord.X & 0xFFFF) + + +#: If True: write the output of the renderer also to the following file. This +#: is very useful for debugging. (e.g.: to see that we don't write more bytes +#: than required.) +_DEBUG_RENDER_OUTPUT = False +_DEBUG_RENDER_OUTPUT_FILENAME = r'prompt-toolkit-windows-output.log' + + +class NoConsoleScreenBufferError(Exception): + """ + Raised when the application is not running inside a Windows Console, but + the user tries to instantiate Win32Output. + """ + def __init__(self): + # Are we running in 'xterm' on Windows, like git-bash for instance? + xterm = 'xterm' in os.environ.get('TERM', '') + + if xterm: + message = ('Found %s, while expecting a Windows console. ' + 'Maybe try to run this program using "winpty" ' + 'or run it in cmd.exe instead. Or otherwise, ' + 'in case of Cygwin, use the Python executable ' + 'that is compiled for Cygwin.' % os.environ['TERM']) + else: + message = 'No Windows console found. Are you running cmd.exe?' + super(NoConsoleScreenBufferError, self).__init__(message) + + +class Win32Output(Output): + """ + I/O abstraction for rendering to Windows consoles. + (cmd.exe and similar.) + """ + def __init__(self, stdout, use_complete_width=False): + self.use_complete_width = use_complete_width + + self._buffer = [] + self.stdout = stdout + self.hconsole = windll.kernel32.GetStdHandle(STD_OUTPUT_HANDLE) + + self._in_alternate_screen = False + + self.color_lookup_table = ColorLookupTable() + + # Remember the default console colors. + info = self.get_win32_screen_buffer_info() + self.default_attrs = info.wAttributes if info else 15 + + if _DEBUG_RENDER_OUTPUT: + self.LOG = open(_DEBUG_RENDER_OUTPUT_FILENAME, 'ab') + + def fileno(self): + " Return file descriptor. " + return self.stdout.fileno() + + def encoding(self): + " Return encoding used for stdout. " + return self.stdout.encoding + + def write(self, data): + self._buffer.append(data) + + def write_raw(self, data): + " For win32, there is no difference between write and write_raw. " + self.write(data) + + def get_size(self): + from libs.prompt_toolkit.layout.screen import Size + info = self.get_win32_screen_buffer_info() + + # We take the width of the *visible* region as the size. Not the width + # of the complete screen buffer. (Unless use_complete_width has been + # set.) + if self.use_complete_width: + width = info.dwSize.X + else: + width = info.srWindow.Right - info.srWindow.Left + + height = info.srWindow.Bottom - info.srWindow.Top + 1 + + # We avoid the right margin, windows will wrap otherwise. + maxwidth = info.dwSize.X - 1 + width = min(maxwidth, width) + + # Create `Size` object. + return Size(rows=height, columns=width) + + def _winapi(self, func, *a, **kw): + """ + Flush and call win API function. + """ + self.flush() + + if _DEBUG_RENDER_OUTPUT: + self.LOG.write(('%r' % func.__name__).encode('utf-8') + b'\n') + self.LOG.write(b' ' + ', '.join(['%r' % i for i in a]).encode('utf-8') + b'\n') + self.LOG.write(b' ' + ', '.join(['%r' % type(i) for i in a]).encode('utf-8') + b'\n') + self.LOG.flush() + + try: + return func(*a, **kw) + except ArgumentError as e: + if _DEBUG_RENDER_OUTPUT: + self.LOG.write((' Error in %r %r %s\n' % (func.__name__, e, e)).encode('utf-8')) + + def get_win32_screen_buffer_info(self): + """ + Return Screen buffer info. + """ + # NOTE: We don't call the `GetConsoleScreenBufferInfo` API through + # `self._winapi`. Doing so causes Python to crash on certain 64bit + # Python versions. (Reproduced with 64bit Python 2.7.6, on Windows + # 10). It is not clear why. Possibly, it has to do with passing + # these objects as an argument, or through *args. + + # The Python documentation contains the following - possibly related - warning: + # ctypes does not support passing unions or structures with + # bit-fields to functions by value. While this may work on 32-bit + # x86, it's not guaranteed by the library to work in the general + # case. Unions and structures with bit-fields should always be + # passed to functions by pointer. + + # Also see: + # - https://github.com/ipython/ipython/issues/10070 + # - https://github.com/jonathanslenders/python-prompt-toolkit/issues/406 + # - https://github.com/jonathanslenders/python-prompt-toolkit/issues/86 + + self.flush() + sbinfo = CONSOLE_SCREEN_BUFFER_INFO() + success = windll.kernel32.GetConsoleScreenBufferInfo(self.hconsole, byref(sbinfo)) + + # success = self._winapi(windll.kernel32.GetConsoleScreenBufferInfo, + # self.hconsole, byref(sbinfo)) + + if success: + return sbinfo + else: + raise NoConsoleScreenBufferError + + def set_title(self, title): + """ + Set terminal title. + """ + assert isinstance(title, six.text_type) + self._winapi(windll.kernel32.SetConsoleTitleW, title) + + def clear_title(self): + self._winapi(windll.kernel32.SetConsoleTitleW, '') + + def erase_screen(self): + start = COORD(0, 0) + sbinfo = self.get_win32_screen_buffer_info() + length = sbinfo.dwSize.X * sbinfo.dwSize.Y + + self.cursor_goto(row=0, column=0) + self._erase(start, length) + + def erase_down(self): + sbinfo = self.get_win32_screen_buffer_info() + size = sbinfo.dwSize + + start = sbinfo.dwCursorPosition + length = ((size.X - size.X) + size.X * (size.Y - sbinfo.dwCursorPosition.Y)) + + self._erase(start, length) + + def erase_end_of_line(self): + """ + """ + sbinfo = self.get_win32_screen_buffer_info() + start = sbinfo.dwCursorPosition + length = sbinfo.dwSize.X - sbinfo.dwCursorPosition.X + + self._erase(start, length) + + def _erase(self, start, length): + chars_written = c_ulong() + + self._winapi(windll.kernel32.FillConsoleOutputCharacterA, + self.hconsole, c_char(b' '), DWORD(length), _coord_byval(start), + byref(chars_written)) + + # Reset attributes. + sbinfo = self.get_win32_screen_buffer_info() + self._winapi(windll.kernel32.FillConsoleOutputAttribute, + self.hconsole, sbinfo.wAttributes, length, _coord_byval(start), + byref(chars_written)) + + def reset_attributes(self): + " Reset the console foreground/background color. " + self._winapi(windll.kernel32.SetConsoleTextAttribute, self.hconsole, + self.default_attrs) + + def set_attributes(self, attrs): + fgcolor, bgcolor, bold, underline, italic, blink, reverse = attrs + + # Start from the default attributes. + attrs = self.default_attrs + + # Override the last four bits: foreground color. + if fgcolor is not None: + attrs = attrs & ~0xf + attrs |= self.color_lookup_table.lookup_fg_color(fgcolor) + + # Override the next four bits: background color. + if bgcolor is not None: + attrs = attrs & ~0xf0 + attrs |= self.color_lookup_table.lookup_bg_color(bgcolor) + + # Reverse: swap these four bits groups. + if reverse: + attrs = (attrs & ~0xff) | ((attrs & 0xf) << 4) | ((attrs & 0xf0) >> 4) + + self._winapi(windll.kernel32.SetConsoleTextAttribute, self.hconsole, attrs) + + def disable_autowrap(self): + # Not supported by Windows. + pass + + def enable_autowrap(self): + # Not supported by Windows. + pass + + def cursor_goto(self, row=0, column=0): + pos = COORD(x=column, y=row) + self._winapi(windll.kernel32.SetConsoleCursorPosition, self.hconsole, _coord_byval(pos)) + + def cursor_up(self, amount): + sr = self.get_win32_screen_buffer_info().dwCursorPosition + pos = COORD(sr.X, sr.Y - amount) + self._winapi(windll.kernel32.SetConsoleCursorPosition, self.hconsole, _coord_byval(pos)) + + def cursor_down(self, amount): + self.cursor_up(-amount) + + def cursor_forward(self, amount): + sr = self.get_win32_screen_buffer_info().dwCursorPosition +# assert sr.X + amount >= 0, 'Negative cursor position: x=%r amount=%r' % (sr.X, amount) + + pos = COORD(max(0, sr.X + amount), sr.Y) + self._winapi(windll.kernel32.SetConsoleCursorPosition, self.hconsole, _coord_byval(pos)) + + def cursor_backward(self, amount): + self.cursor_forward(-amount) + + def flush(self): + """ + Write to output stream and flush. + """ + if not self._buffer: + # Only flush stdout buffer. (It could be that Python still has + # something in its buffer. -- We want to be sure to print that in + # the correct color.) + self.stdout.flush() + return + + data = ''.join(self._buffer) + + if _DEBUG_RENDER_OUTPUT: + self.LOG.write(('%r' % data).encode('utf-8') + b'\n') + self.LOG.flush() + + # Print characters one by one. This appears to be the best soluton + # in oder to avoid traces of vertical lines when the completion + # menu disappears. + for b in data: + written = DWORD() + + retval = windll.kernel32.WriteConsoleW(self.hconsole, b, 1, byref(written), None) + assert retval != 0 + + self._buffer = [] + + def get_rows_below_cursor_position(self): + info = self.get_win32_screen_buffer_info() + return info.srWindow.Bottom - info.dwCursorPosition.Y + 1 + + def scroll_buffer_to_prompt(self): + """ + To be called before drawing the prompt. This should scroll the console + to left, with the cursor at the bottom (if possible). + """ + # Get current window size + info = self.get_win32_screen_buffer_info() + sr = info.srWindow + cursor_pos = info.dwCursorPosition + + result = SMALL_RECT() + + # Scroll to the left. + result.Left = 0 + result.Right = sr.Right - sr.Left + + # Scroll vertical + win_height = sr.Bottom - sr.Top + if 0 < sr.Bottom - cursor_pos.Y < win_height - 1: + # no vertical scroll if cursor already on the screen + result.Bottom = sr.Bottom + else: + result.Bottom = max(win_height, cursor_pos.Y) + result.Top = result.Bottom - win_height + + # Scroll API + self._winapi(windll.kernel32.SetConsoleWindowInfo, self.hconsole, True, byref(result)) + + def enter_alternate_screen(self): + """ + Go to alternate screen buffer. + """ + if not self._in_alternate_screen: + GENERIC_READ = 0x80000000 + GENERIC_WRITE = 0x40000000 + + # Create a new console buffer and activate that one. + handle = self._winapi(windll.kernel32.CreateConsoleScreenBuffer, GENERIC_READ|GENERIC_WRITE, + DWORD(0), None, DWORD(1), None) + + self._winapi(windll.kernel32.SetConsoleActiveScreenBuffer, handle) + self.hconsole = handle + self._in_alternate_screen = True + + def quit_alternate_screen(self): + """ + Make stdout again the active buffer. + """ + if self._in_alternate_screen: + stdout = self._winapi(windll.kernel32.GetStdHandle, STD_OUTPUT_HANDLE) + self._winapi(windll.kernel32.SetConsoleActiveScreenBuffer, stdout) + self._winapi(windll.kernel32.CloseHandle, self.hconsole) + self.hconsole = stdout + self._in_alternate_screen = False + + def enable_mouse_support(self): + ENABLE_MOUSE_INPUT = 0x10 + handle = windll.kernel32.GetStdHandle(STD_INPUT_HANDLE) + + original_mode = DWORD() + self._winapi(windll.kernel32.GetConsoleMode, handle, pointer(original_mode)) + self._winapi(windll.kernel32.SetConsoleMode, handle, original_mode.value | ENABLE_MOUSE_INPUT) + + def disable_mouse_support(self): + ENABLE_MOUSE_INPUT = 0x10 + handle = windll.kernel32.GetStdHandle(STD_INPUT_HANDLE) + + original_mode = DWORD() + self._winapi(windll.kernel32.GetConsoleMode, handle, pointer(original_mode)) + self._winapi(windll.kernel32.SetConsoleMode, handle, original_mode.value & ~ ENABLE_MOUSE_INPUT) + + def hide_cursor(self): + pass + + def show_cursor(self): + pass + + @classmethod + def win32_refresh_window(cls): + """ + Call win32 API to refresh the whole Window. + + This is sometimes necessary when the application paints background + for completion menus. When the menu disappears, it leaves traces due + to a bug in the Windows Console. Sending a repaint request solves it. + """ + # Get console handle + handle = windll.kernel32.GetConsoleWindow() + + RDW_INVALIDATE = 0x0001 + windll.user32.RedrawWindow(handle, None, None, c_uint(RDW_INVALIDATE)) + + +class FOREGROUND_COLOR: + BLACK = 0x0000 + BLUE = 0x0001 + GREEN = 0x0002 + CYAN = 0x0003 + RED = 0x0004 + MAGENTA = 0x0005 + YELLOW = 0x0006 + GRAY = 0x0007 + INTENSITY = 0x0008 # Foreground color is intensified. + + +class BACKROUND_COLOR: + BLACK = 0x0000 + BLUE = 0x0010 + GREEN = 0x0020 + CYAN = 0x0030 + RED = 0x0040 + MAGENTA = 0x0050 + YELLOW = 0x0060 + GRAY = 0x0070 + INTENSITY = 0x0080 # Background color is intensified. + + +def _create_ansi_color_dict(color_cls): + " Create a table that maps the 16 named ansi colors to their Windows code. " + return { + 'ansidefault': color_cls.BLACK, + 'ansiblack': color_cls.BLACK, + 'ansidarkgray': color_cls.BLACK | color_cls.INTENSITY, + 'ansilightgray': color_cls.GRAY, + 'ansiwhite': color_cls.GRAY | color_cls.INTENSITY, + + # Low intensity. + 'ansidarkred': color_cls.RED, + 'ansidarkgreen': color_cls.GREEN, + 'ansibrown': color_cls.YELLOW, + 'ansidarkblue': color_cls.BLUE, + 'ansipurple': color_cls.MAGENTA, + 'ansiteal': color_cls.CYAN, + + # High intensity. + 'ansired': color_cls.RED | color_cls.INTENSITY, + 'ansigreen': color_cls.GREEN | color_cls.INTENSITY, + 'ansiyellow': color_cls.YELLOW | color_cls.INTENSITY, + 'ansiblue': color_cls.BLUE | color_cls.INTENSITY, + 'ansifuchsia': color_cls.MAGENTA | color_cls.INTENSITY, + 'ansiturquoise': color_cls.CYAN | color_cls.INTENSITY, + } + +FG_ANSI_COLORS = _create_ansi_color_dict(FOREGROUND_COLOR) +BG_ANSI_COLORS = _create_ansi_color_dict(BACKROUND_COLOR) + +assert set(FG_ANSI_COLORS) == set(ANSI_COLOR_NAMES) +assert set(BG_ANSI_COLORS) == set(ANSI_COLOR_NAMES) + + +class ColorLookupTable(object): + """ + Inspired by pygments/formatters/terminal256.py + """ + def __init__(self): + self._win32_colors = self._build_color_table() + self.best_match = {} # Cache + + @staticmethod + def _build_color_table(): + """ + Build an RGB-to-256 color conversion table + """ + FG = FOREGROUND_COLOR + BG = BACKROUND_COLOR + + return [ + (0x00, 0x00, 0x00, FG.BLACK, BG.BLACK), + (0x00, 0x00, 0xaa, FG.BLUE, BG.BLUE), + (0x00, 0xaa, 0x00, FG.GREEN, BG.GREEN), + (0x00, 0xaa, 0xaa, FG.CYAN, BG.CYAN), + (0xaa, 0x00, 0x00, FG.RED, BG.RED), + (0xaa, 0x00, 0xaa, FG.MAGENTA, BG.MAGENTA), + (0xaa, 0xaa, 0x00, FG.YELLOW, BG.YELLOW), + (0x88, 0x88, 0x88, FG.GRAY, BG.GRAY), + + (0x44, 0x44, 0xff, FG.BLUE | FG.INTENSITY, BG.BLUE | BG.INTENSITY), + (0x44, 0xff, 0x44, FG.GREEN | FG.INTENSITY, BG.GREEN | BG.INTENSITY), + (0x44, 0xff, 0xff, FG.CYAN | FG.INTENSITY, BG.CYAN | BG.INTENSITY), + (0xff, 0x44, 0x44, FG.RED | FG.INTENSITY, BG.RED | BG.INTENSITY), + (0xff, 0x44, 0xff, FG.MAGENTA | FG.INTENSITY, BG.MAGENTA | BG.INTENSITY), + (0xff, 0xff, 0x44, FG.YELLOW | FG.INTENSITY, BG.YELLOW | BG.INTENSITY), + + (0x44, 0x44, 0x44, FG.BLACK | FG.INTENSITY, BG.BLACK | BG.INTENSITY), + (0xff, 0xff, 0xff, FG.GRAY | FG.INTENSITY, BG.GRAY | BG.INTENSITY), + ] + + def _closest_color(self, r, g, b): + distance = 257 * 257 * 3 # "infinity" (>distance from #000000 to #ffffff) + fg_match = 0 + bg_match = 0 + + for r_, g_, b_, fg_, bg_ in self._win32_colors: + rd = r - r_ + gd = g - g_ + bd = b - b_ + + d = rd * rd + gd * gd + bd * bd + + if d < distance: + fg_match = fg_ + bg_match = bg_ + distance = d + return fg_match, bg_match + + def _color_indexes(self, color): + indexes = self.best_match.get(color, None) + if indexes is None: + try: + rgb = int(str(color), 16) + except ValueError: + rgb = 0 + + r = (rgb >> 16) & 0xff + g = (rgb >> 8) & 0xff + b = rgb & 0xff + indexes = self._closest_color(r, g, b) + self.best_match[color] = indexes + return indexes + + def lookup_fg_color(self, fg_color): + """ + Return the color for use in the + `windll.kernel32.SetConsoleTextAttribute` API call. + + :param fg_color: Foreground as text. E.g. 'ffffff' or 'red' + """ + # Foreground. + if fg_color in FG_ANSI_COLORS: + return FG_ANSI_COLORS[fg_color] + else: + return self._color_indexes(fg_color)[0] + + def lookup_bg_color(self, bg_color): + """ + Return the color for use in the + `windll.kernel32.SetConsoleTextAttribute` API call. + + :param bg_color: Background as text. E.g. 'ffffff' or 'red' + """ + # Background. + if bg_color in BG_ANSI_COLORS: + return BG_ANSI_COLORS[bg_color] + else: + return self._color_indexes(bg_color)[1] diff --git a/src/libs/prompt_toolkit/token.py b/src/libs/prompt_toolkit/token.py new file mode 100644 index 0000000..86caaeb --- /dev/null +++ b/src/libs/prompt_toolkit/token.py @@ -0,0 +1,47 @@ +""" +The Token class, interchangeable with ``pygments.token``. + +A `Token` has some semantics for a piece of text that is given a style through +a :class:`~libs.prompt_toolkit.styles.Style` class. A pygments lexer for instance, +returns a list of (Token, text) tuples. Each fragment of text has a token +assigned, which when combined with a style sheet, will determine the fine +style. +""" + +# If we don't need any lexers or style classes from Pygments, we don't want +# Pygments to be installed for only the following 10 lines of code. So, there +# is some duplication, but this should stay compatible with Pygments. + +__all__ = ( + 'Token', + 'ZeroWidthEscape', +) + + +class _TokenType(tuple): + def __getattr__(self, val): + if not val or not val[0].isupper(): + return tuple.__getattribute__(self, val) + + new = _TokenType(self + (val,)) + setattr(self, val, new) + return new + + def __repr__(self): + return 'Token' + (self and '.' or '') + '.'.join(self) + + +# Prefer the Token class from Pygments. If Pygments is not installed, use our +# minimalistic Token class. +try: + from pygments.token import Token +except ImportError: + Token = _TokenType() + + +# Built-in tokens: + +#: `ZeroWidthEscape` can be used for raw VT escape sequences that don't +#: cause the cursor position to move. (E.g. FinalTerm's escape sequences +#: for shell integration.) +ZeroWidthEscape = Token.ZeroWidthEscape diff --git a/src/libs/prompt_toolkit/utils.py b/src/libs/prompt_toolkit/utils.py new file mode 100644 index 0000000..3cd9318 --- /dev/null +++ b/src/libs/prompt_toolkit/utils.py @@ -0,0 +1,240 @@ +from __future__ import unicode_literals +import inspect +import os +import signal +import sys +import threading +import weakref + +from wcwidth import wcwidth +from six.moves import range + + +__all__ = ( + 'Event', + 'DummyContext', + 'get_cwidth', + 'suspend_to_background_supported', + 'is_conemu_ansi', + 'is_windows', + 'in_main_thread', + 'take_using_weights', + 'test_callable_args', +) + + +class Event(object): + """ + Simple event to which event handlers can be attached. For instance:: + + class Cls: + def __init__(self): + # Define event. The first parameter is the sender. + self.event = Event(self) + + obj = Cls() + + def handler(sender): + pass + + # Add event handler by using the += operator. + obj.event += handler + + # Fire event. + obj.event() + """ + def __init__(self, sender, handler=None): + self.sender = sender + self._handlers = [] + + if handler is not None: + self += handler + + def __call__(self): + " Fire event. " + for handler in self._handlers: + handler(self.sender) + + def fire(self): + " Alias for just calling the event. " + self() + + def __iadd__(self, handler): + """ + Add another handler to this callback. + (Handler should be a callable that takes exactly one parameter: the + sender object.) + """ + # Test handler. + assert callable(handler) + if not test_callable_args(handler, [None]): + raise TypeError("%r doesn't take exactly one argument." % handler) + + # Add to list of event handlers. + self._handlers.append(handler) + return self + + def __isub__(self, handler): + """ + Remove a handler from this callback. + """ + self._handlers.remove(handler) + return self + + +# Cache of signatures. Improves the performance of `test_callable_args`. +_signatures_cache = weakref.WeakKeyDictionary() + + +def test_callable_args(func, args): + """ + Return True when this function can be called with the given arguments. + """ + assert isinstance(args, (list, tuple)) + signature = getattr(inspect, 'signature', None) + + if signature is not None: + # For Python 3, use inspect.signature. + try: + sig = _signatures_cache[func] + except KeyError: + sig = signature(func) + _signatures_cache[func] = sig + + try: + sig.bind(*args) + except TypeError: + return False + else: + return True + else: + # For older Python versions, fall back to using getargspec. + spec = inspect.getargspec(func) + + # Drop the 'self' + def drop_self(spec): + args, varargs, varkw, defaults = spec + if args[0:1] == ['self']: + args = args[1:] + return inspect.ArgSpec(args, varargs, varkw, defaults) + + spec = drop_self(spec) + + # When taking *args, always return True. + if spec.varargs is not None: + return True + + # Test whether the given amount of args is between the min and max + # accepted argument counts. + return len(spec.args) - len(spec.defaults or []) <= len(args) <= len(spec.args) + + +class DummyContext(object): + """ + (contextlib.nested is not available on Py3) + """ + def __enter__(self): + pass + + def __exit__(self, *a): + pass + + +class _CharSizesCache(dict): + """ + Cache for wcwidth sizes. + """ + def __missing__(self, string): + # Note: We use the `max(0, ...` because some non printable control + # characters, like e.g. Ctrl-underscore get a -1 wcwidth value. + # It can be possible that these characters end up in the input + # text. + if len(string) == 1: + result = max(0, wcwidth(string)) + else: + result = sum(max(0, wcwidth(c)) for c in string) + + # Cache for short strings. + # (It's hard to tell what we can consider short...) + if len(string) < 256: + self[string] = result + + return result + + +_CHAR_SIZES_CACHE = _CharSizesCache() + + +def get_cwidth(string): + """ + Return width of a string. Wrapper around ``wcwidth``. + """ + return _CHAR_SIZES_CACHE[string] + + +def suspend_to_background_supported(): + """ + Returns `True` when the Python implementation supports + suspend-to-background. This is typically `False' on Windows systems. + """ + return hasattr(signal, 'SIGTSTP') + + +def is_windows(): + """ + True when we are using Windows. + """ + return sys.platform.startswith('win') # E.g. 'win32', not 'darwin' or 'linux2' + + +def is_conemu_ansi(): + """ + True when the ConEmu Windows console is used. + """ + return is_windows() and os.environ.get('ConEmuANSI', 'OFF') == 'ON' + + +def in_main_thread(): + """ + True when the current thread is the main thread. + """ + return threading.current_thread().__class__.__name__ == '_MainThread' + + +def take_using_weights(items, weights): + """ + Generator that keeps yielding items from the items list, in proportion to + their weight. For instance:: + + # Getting the first 70 items from this generator should have yielded 10 + # times A, 20 times B and 40 times C, all distributed equally.. + take_using_weights(['A', 'B', 'C'], [5, 10, 20]) + + :param items: List of items to take from. + :param weights: Integers representing the weight. (Numbers have to be + integers, not floats.) + """ + assert isinstance(items, list) + assert isinstance(weights, list) + assert all(isinstance(i, int) for i in weights) + assert len(items) == len(weights) + assert len(items) > 0 + + already_taken = [0 for i in items] + item_count = len(items) + max_weight = max(weights) + + i = 0 + while True: + # Each iteration of this loop, we fill up until by (total_weight/max_weight). + adding = True + while adding: + adding = False + + for item_i, item, weight in zip(range(item_count), items, weights): + if already_taken[item_i] < i * weight / float(max_weight): + yield item + already_taken[item_i] += 1 + adding = True + + i += 1 diff --git a/src/libs/prompt_toolkit/validation.py b/src/libs/prompt_toolkit/validation.py new file mode 100644 index 0000000..39a27a4 --- /dev/null +++ b/src/libs/prompt_toolkit/validation.py @@ -0,0 +1,64 @@ +""" +Input validation for a `Buffer`. +(Validators will be called before accepting input.) +""" +from __future__ import unicode_literals +from .filters import to_simple_filter + +from abc import ABCMeta, abstractmethod +from six import with_metaclass + +__all__ = ( + 'ConditionalValidator', + 'ValidationError', + 'Validator', +) + + +class ValidationError(Exception): + """ + Error raised by :meth:`.Validator.validate`. + + :param cursor_position: The cursor position where the error occured. + :param message: Text. + """ + def __init__(self, cursor_position=0, message=''): + super(ValidationError, self).__init__(message) + self.cursor_position = cursor_position + self.message = message + + def __repr__(self): + return '%s(cursor_position=%r, message=%r)' % ( + self.__class__.__name__, self.cursor_position, self.message) + + +class Validator(with_metaclass(ABCMeta, object)): + """ + Abstract base class for an input validator. + """ + @abstractmethod + def validate(self, document): + """ + Validate the input. + If invalid, this should raise a :class:`.ValidationError`. + + :param document: :class:`~libs.prompt_toolkit.document.Document` instance. + """ + pass + + +class ConditionalValidator(Validator): + """ + Validator that can be switched on/off according to + a filter. (This wraps around another validator.) + """ + def __init__(self, validator, filter): + assert isinstance(validator, Validator) + + self.validator = validator + self.filter = to_simple_filter(filter) + + def validate(self, document): + # Call the validator only if the filter is active. + if self.filter(): + self.validator.validate(document) diff --git a/src/libs/prompt_toolkit/win32_types.py b/src/libs/prompt_toolkit/win32_types.py new file mode 100644 index 0000000..ba2d90d --- /dev/null +++ b/src/libs/prompt_toolkit/win32_types.py @@ -0,0 +1,155 @@ +from ctypes import Union, Structure, c_char, c_short, c_long, c_ulong +from ctypes.wintypes import DWORD, BOOL, LPVOID, WORD, WCHAR + + +# Input/Output standard device numbers. Note that these are not handle objects. +# It's the `windll.kernel32.GetStdHandle` system call that turns them into a +# real handle object. +STD_INPUT_HANDLE = c_ulong(-10) +STD_OUTPUT_HANDLE = c_ulong(-11) +STD_ERROR_HANDLE = c_ulong(-12) + + +class COORD(Structure): + """ + Struct in wincon.h + http://msdn.microsoft.com/en-us/library/windows/desktop/ms682119(v=vs.85).aspx + """ + _fields_ = [ + ('X', c_short), # Short + ('Y', c_short), # Short + ] + + def __repr__(self): + return '%s(X=%r, Y=%r, type_x=%r, type_y=%r)' % ( + self.__class__.__name__, self.X, self.Y, type(self.X), type(self.Y)) + + +class UNICODE_OR_ASCII(Union): + _fields_ = [ + ('AsciiChar', c_char), + ('UnicodeChar', WCHAR), + ] + + +class KEY_EVENT_RECORD(Structure): + """ + http://msdn.microsoft.com/en-us/library/windows/desktop/ms684166(v=vs.85).aspx + """ + _fields_ = [ + ('KeyDown', c_long), # bool + ('RepeatCount', c_short), # word + ('VirtualKeyCode', c_short), # word + ('VirtualScanCode', c_short), # word + ('uChar', UNICODE_OR_ASCII), # Unicode or ASCII. + ('ControlKeyState', c_long) # double word + ] + + +class MOUSE_EVENT_RECORD(Structure): + """ + http://msdn.microsoft.com/en-us/library/windows/desktop/ms684239(v=vs.85).aspx + """ + _fields_ = [ + ('MousePosition', COORD), + ('ButtonState', c_long), # dword + ('ControlKeyState', c_long), # dword + ('EventFlags', c_long) # dword + ] + + +class WINDOW_BUFFER_SIZE_RECORD(Structure): + """ + http://msdn.microsoft.com/en-us/library/windows/desktop/ms687093(v=vs.85).aspx + """ + _fields_ = [ + ('Size', COORD) + ] + + +class MENU_EVENT_RECORD(Structure): + """ + http://msdn.microsoft.com/en-us/library/windows/desktop/ms684213(v=vs.85).aspx + """ + _fields_ = [ + ('CommandId', c_long) # uint + ] + + +class FOCUS_EVENT_RECORD(Structure): + """ + http://msdn.microsoft.com/en-us/library/windows/desktop/ms683149(v=vs.85).aspx + """ + _fields_ = [ + ('SetFocus', c_long) # bool + ] + + +class EVENT_RECORD(Union): + _fields_ = [ + ('KeyEvent', KEY_EVENT_RECORD), + ('MouseEvent', MOUSE_EVENT_RECORD), + ('WindowBufferSizeEvent', WINDOW_BUFFER_SIZE_RECORD), + ('MenuEvent', MENU_EVENT_RECORD), + ('FocusEvent', FOCUS_EVENT_RECORD) + ] + + +class INPUT_RECORD(Structure): + """ + http://msdn.microsoft.com/en-us/library/windows/desktop/ms683499(v=vs.85).aspx + """ + _fields_ = [ + ('EventType', c_short), # word + ('Event', EVENT_RECORD) # Union. + ] + + +EventTypes = { + 1: 'KeyEvent', + 2: 'MouseEvent', + 4: 'WindowBufferSizeEvent', + 8: 'MenuEvent', + 16: 'FocusEvent' +} + + +class SMALL_RECT(Structure): + """struct in wincon.h.""" + _fields_ = [ + ("Left", c_short), + ("Top", c_short), + ("Right", c_short), + ("Bottom", c_short), + ] + + +class CONSOLE_SCREEN_BUFFER_INFO(Structure): + """struct in wincon.h.""" + _fields_ = [ + ("dwSize", COORD), + ("dwCursorPosition", COORD), + ("wAttributes", WORD), + ("srWindow", SMALL_RECT), + ("dwMaximumWindowSize", COORD), + ] + + def __str__(self): + return '(%d,%d,%d,%d,%d,%d,%d,%d,%d,%d,%d)' % ( + self.dwSize.Y, self.dwSize.X, + self.dwCursorPosition.Y, self.dwCursorPosition.X, + self.wAttributes, + self.srWindow.Top, self.srWindow.Left, self.srWindow.Bottom, self.srWindow.Right, + self.dwMaximumWindowSize.Y, self.dwMaximumWindowSize.X, + ) + + +class SECURITY_ATTRIBUTES(Structure): + """ + http://msdn.microsoft.com/en-us/library/windows/desktop/aa379560(v=vs.85).aspx + """ + _fields_ = [ + ('nLength', DWORD), + ('lpSecurityDescriptor', LPVOID), + ('bInheritHandle', BOOL), + ] diff --git a/src/shellmen b/src/shellmen deleted file mode 100755 index 507bcae..0000000 --- a/src/shellmen +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -# set -o xtrace ## To debug scripts -# set -o errexit ## To exit on error -# set -o errunset ## To exit if a variable is referenced but not set - - -function main() { - SCRIPTPATH="$( cd "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" - cd "${SCRIPTPATH}" - python3 . -} -main $@; diff --git a/src/signal_classes/Controller.py b/src/signal_classes/Controller.py deleted file mode 100644 index c447326..0000000 --- a/src/signal_classes/Controller.py +++ /dev/null @@ -1,185 +0,0 @@ -# Python imports -import threading, subprocess, traceback -from os.path import isfile -from os import listdir - - -# Gtk imports -from xdg.DesktopEntry import DesktopEntry - -# Application imports -from .mixins import * -from . import Menu, Controller_Data - - - -def threaded(fn): - def wrapper(*args, **kwargs): - threading.Thread(target=fn, args=args, kwargs=kwargs).start() - - return wrapper - - -class Controller(ProcessorMixin, Menu, Controller_Data): - def __init__(self, _settings, args, unknownargs): - super().__init__(_settings, args) - - self.setup_controller_data(_settings) - - base_options = ["[ TO MAIN MENU ]", "Favorites"] - self.menu_data = self.get_desktop_files_info(self.app_paths) - query = "" - - while True: - try: - self.clear_console() - group = self.call_method("main_menu")["group"] - self.clear_console() - - if "Search..." in group: - query = self.call_method("search_menu")["query"] - if "[ Set Favorites ]" in group: - programs_list = self.get_sub_group("Search...", "") - fixed_programs_list = [] - - for program in programs_list: - fixed_programs_list.append({'name': program}) - - self.favorites = self.call_method("set_favorites_menu", [fixed_programs_list])["set_faves"] - self.settings.save_faves(self.favorites) - continue - if "[ Exit ]" in group: - break - - programs_list = ["[ TO MAIN MENU ]"] - programs_list += self.get_sub_group(group, query) - entry = self.call_method("sub_menu", [group, programs_list])["prog"] - - if entry not in base_options: - self.execute_program(self.flat_menu_data, entry) - except Exception as e: - self.logger.debug(f"Traceback: {traceback.print_exc()}") - self.logger.debug(f"Exception: {e}") - - - - def get_desktop_files_info(self, paths): - menu_objects = { - "Accessories": {}, - "Multimedia": {}, - "Graphics": {}, - "Game": {}, - "Office": {}, - "Development": {}, - "Internet": {}, - "Settings": {}, - "System": {}, - "Wine": {}, - "Other": {} - } - - for path in paths: - self.list_and_update_desktop_iles(path, menu_objects); - - return menu_objects - - def list_and_update_desktop_iles(self, path, menu_objects): - try: - for f in listdir(path): - full_path = f"{path}/{f}" - if isfile(full_path) and f.endswith(".desktop"): - xdg_object = DesktopEntry(full_path) - hidden = xdg_object.getHidden() - nodisplay = xdg_object.getNoDisplay() - type = xdg_object.getType() - groups = xdg_object.getCategories() - # Do not show those marked as hidden or not to display - if hidden or nodisplay: - continue - - if type == "Application" and groups != "": - title = xdg_object.getName() - comment = xdg_object.getComment() - # icon = xdg_object.getIcon() - main_exec = xdg_object.getExec() - try_exec = xdg_object.getTryExec() - - group = "" - if "Accessories" in groups or "Utility" in groups: - group = "Accessories" - elif "Multimedia" in groups or "Video" in groups or "Audio" in groups: - group = "Multimedia" - elif "Development" in groups: - group = "Development" - elif "Game" in groups: - group = "Game" - elif "Internet" in groups or "Network" in groups: - group = "Internet" - elif "Graphics" in groups: - group = "Graphics" - elif "Office" in groups: - group = "Office" - elif "System" in groups: - group = "System" - elif "Settings" in groups: - group = "Settings" - elif "Wine" in groups: - group = "Wine" - else: - group = "Other" - - chunk_data = { - "groups": groups, "comment": comment, - "exec": main_exec, "try_exec": try_exec, - "fileName": f - } - - menu_objects[group][title] = chunk_data - self.flat_menu_data[title] = chunk_data - except Exception as e: - self.logger.debug(e) - - - def get_sub_group(self, group, query = ""): - desktop_objects = [] - if "Search..." in group: - for key in self.flat_menu_data.keys(): - option = self.flat_menu_data[key] - keys = option.keys() - - if "comment" in keys and len(option["comment"]) > 0: - if query.lower() in option["comment"].lower(): - desktop_objects.append( f"{key} || {option['comment']}" ) - elif query.lower() in key.lower() or query.lower() in option["fileName"].lower(): - desktop_objects.append( f"{key} || {option['fileName'].replace('.desktop', '')}" ) - elif "Favorites" in group: - desktop_objects = self.favorites - else: - for key in self.menu_data[group]: - option = self.flat_menu_data[key] - keys = option.keys() - if "comment" in keys and len(option["comment"]) > 0: - if query.lower() in option["comment"].lower(): - desktop_objects.append( f"{key} || {option['comment']}" ) - elif query.lower() in key.lower() or query.lower() in option["fileName"].lower(): - desktop_objects.append( f"{key} || {option['fileName'].replace('.desktop', '')}" ) - - return desktop_objects - - - - - def tear_down(self, widget=None, eve=None): - quit() - - def get_clipboard_data(self): - proc = subprocess.Popen(['xclip','-selection', 'clipboard', '-o'], stdout=subprocess.PIPE) - retcode = proc.wait() - data = proc.stdout.read() - return data.decode("utf-8").strip() - - def set_clipboard_data(self, data): - proc = subprocess.Popen(['xclip','-selection','clipboard'], stdin=subprocess.PIPE) - proc.stdin.write(data) - proc.stdin.close() - retcode = proc.wait() diff --git a/src/signal_classes/Controller_Data.py b/src/signal_classes/Controller_Data.py deleted file mode 100644 index 13b2122..0000000 --- a/src/signal_classes/Controller_Data.py +++ /dev/null @@ -1,32 +0,0 @@ -# Python imports -import os, signal - -# Lib imports -from gi.repository import GLib - -# Application imports - - - -class Controller_Data: - def clear_console(self): - os.system('cls' if os.name == 'nt' else 'clear') - - def call_method(self, _method_name, data = None): - method_name = str(_method_name) - method = getattr(self, method_name, lambda data: f"No valid key passed...\nkey={method_name}\nargs={data}") - return method(data) if data else method() - - def has_method(self, obj, name): - return callable(getattr(obj, name, None)) - - def setup_controller_data(self, _settings): - self.settings = _settings - self.logger = self.settings.get_logger() - self.app_paths = self.settings.get_app_paths() - self.favorites_path = self.settings.get_favorites_path() - self.favorites = self.settings.get_favorites() - self.menu_data = None - self.flat_menu_data = {} - - GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, self.tear_down) diff --git a/src/signal_classes/Menu.py b/src/signal_classes/Menu.py deleted file mode 100644 index 4516b22..0000000 --- a/src/signal_classes/Menu.py +++ /dev/null @@ -1,88 +0,0 @@ -# Python imports - -from __future__ import print_function, unicode_literals -# from pprint import pprint -import json - - -# Lib imports -from PyInquirer import style_from_dict, Token, prompt, Separator - -# Application imports -from .mixins import StylesMixin - - - - -GROUPS = [ "Search...", "Favorites", "Accessories", "Multimedia", "Graphics", "Office", - "Development", "Internet", "Settings", "System", "Game", "Wine", - "Other", "[ Set Favorites ]", "[ Exit ]" - ] - - -class Menu(StylesMixin): - """ - The menu class has sub methods that are called per run. - """ - def __init__(self, settings, args): - """ - Construct a new 'Menu' object which pulls in mixins. - :param args: The terminal passed arguments - - :return: returns nothing - """ - self.logger = settings.get_logger() - self.theme = self.call_method(args.theme) - - - def main_menu(self, _group_list = None): - """ - Displays the main menu using the defined GROUPS list... - """ - group_list = GROUPS if not _group_list else _group_list - menu = { - 'type': 'list', - 'name': 'group', - 'message': '[ MAIN MENU ]', - 'choices': group_list - } - - return prompt(menu, style=self.theme) - - - def set_favorites_menu(self, _group_list = None): - GROUPS = [{'name': '[ TO MAIN MENU ]'}, {'name': 'This is a stub method for Favorites...'}] - group_list = GROUPS if not _group_list[0] else _group_list[0] - menu = { - 'type': 'checkbox', - 'qmark': '>', - 'message': 'Select Favorites', - 'name': 'set_faves', - 'choices': group_list - } - - return prompt(menu, style=self.theme) - - - def sub_menu(self, data = ["NO GROUP NAME", "NO PROGRAMS PASSED IN"]): - group = data[0] - prog_list = data[1] - - menu = { - 'type': 'list', - 'name': 'prog', - 'message': f'[ {group} ]', - 'choices': prog_list - } - - return prompt(menu, style=self.theme) - - - def search_menu(self): - menu = { - 'type': 'input', - 'name': 'query', - 'message': 'Program you\'re looking for: ', - } - - return prompt(menu, style=self.theme) diff --git a/src/signal_classes/__init__.py b/src/signal_classes/__init__.py deleted file mode 100644 index b212e68..0000000 --- a/src/signal_classes/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -""" - Gtk Bound Signal Module -""" -from .mixins import * -from .Menu import Menu -from .Controller_Data import Controller_Data -from .Controller import Controller diff --git a/src/signal_classes/mixins/ProcessorMixin.py b/src/signal_classes/mixins/ProcessorMixin.py deleted file mode 100644 index 8f12231..0000000 --- a/src/signal_classes/mixins/ProcessorMixin.py +++ /dev/null @@ -1,36 +0,0 @@ -# Python imports -import os, subprocess - -# Lib imports - -# Application imports - - - -class ProcessorMixin: - def execute_program(self, data, entry): - parts = entry.split("||") - title = parts[0].strip() - comment = parts[1].strip() - chunk_data = data[title.strip()] - - self.logger.info(f"[Executing Program]\n\t\tEntry: {entry}\n\t\tChunk Data: {chunk_data}") - self.pre_execute(chunk_data) - - def pre_execute(self, option): - try: - self.execute(option["tryExec"]) - except Exception as e: - self.logger.info(f"[Executing Program]\n\t\t Try exec failed!\n{e}") - try: - if option["exec"] and len(option["exec"]) > 0: - self.execute(option["exec"]) - except Exception as e: - self.logger.debug(e) - - - def execute(self, option): - DEVNULL = open(os.devnull, 'w') - command = option.split("%")[0] - self.logger.debug(command) - subprocess.Popen(command.split(), cwd=os.getenv("HOME"), start_new_session=True, stdout=DEVNULL, stderr=DEVNULL) diff --git a/src/signal_classes/mixins/__init__.py b/src/signal_classes/mixins/__init__.py deleted file mode 100644 index 2ff067c..0000000 --- a/src/signal_classes/mixins/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -""" - Mixins module -""" -from .StylesMixin import StylesMixin -from .ProcessorMixin import ProcessorMixin diff --git a/src/utils/Logger.py b/src/utils/Logger.py deleted file mode 100644 index 06eed47..0000000 --- a/src/utils/Logger.py +++ /dev/null @@ -1,56 +0,0 @@ -# Python imports -import os, logging - -# Application imports - - -class Logger: - def __init__(self, config_path): - self._CONFIG_PATH = config_path - - def get_logger(self, loggerName = "NO_LOGGER_NAME_PASSED", createFile = True): - """ - Create a new logging object and return it. - :note: - NOSET # Don't know the actual log level of this... (defaulting or literally none?) - Log Levels (From least to most) - Type Value - CRITICAL 50 - ERROR 40 - WARNING 30 - INFO 20 - DEBUG 10 - :param loggerName: Sets the name of the logger object. (Used in log lines) - :param createFile: Whether we create a log file or just pump to terminal - - :return: the logging object we created - """ - - globalLogLvl = logging.DEBUG # Keep this at highest so that handlers can filter to their desired levels - chLogLevel = logging.CRITICAL # Prety musch the only one we change ever - fhLogLevel = logging.DEBUG - log = logging.getLogger(loggerName) - log.setLevel(globalLogLvl) - - # Set our log output styles - fFormatter = logging.Formatter('[%(asctime)s] %(pathname)s:%(lineno)d %(levelname)s - %(message)s', '%m-%d %H:%M:%S') - cFormatter = logging.Formatter('%(pathname)s:%(lineno)d] %(levelname)s - %(message)s') - - ch = logging.StreamHandler() - ch.setLevel(level=chLogLevel) - ch.setFormatter(cFormatter) - log.addHandler(ch) - - if createFile: - folder = self._CONFIG_PATH - file = f"{folder}/application.log" - - if not os.path.exists(folder): - os.mkdir(folder) - - fh = logging.FileHandler(file) - fh.setLevel(level=fhLogLevel) - fh.setFormatter(fFormatter) - log.addHandler(fh) - - return log diff --git a/src/utils/Settings.py b/src/utils/Settings.py deleted file mode 100644 index ce6eedb..0000000 --- a/src/utils/Settings.py +++ /dev/null @@ -1,51 +0,0 @@ -# Python imports -import os, json - -# Gtk imports - -# Application imports -from . import Logger - - - -class Settings: - def __init__(self): - self._SCRIPT_PTH = os.path.dirname(os.path.realpath(__file__)) - self._USER_HOME = os.path.expanduser('~') - self._CONFIG_PATH = f"{self._USER_HOME}/.config/{app_name.lower()}" - self._FAVORITES_FILE = f"{self._CONFIG_PATH}/favorites.json" - self._HOME_APPS = f"{self._USER_HOME}/.local/share/applications" - self._APP_PATHS = ["/usr/share/applications", self._HOME_APPS] - - self._logger = Logger(self._CONFIG_PATH).get_logger() - self._faves = [] - - if not os.path.exists(self._CONFIG_PATH): - os.mkdir(self._CONFIG_PATH) - self._logger = Logger(self._CONFIG_PATH).get_logger() - - if not os.path.exists(self._FAVORITES_FILE): - open(self._FAVORITES_FILE, 'a').close() - - - with open(self._FAVORITES_FILE) as f: - try: - self._faves = json.load(f) - except Exception as e: - pass - - f.close() - - - - def save_faves(self, data = None): - with open(self._FAVORITES_FILE, 'w') as f: - json.dump(data, f, separators=(',', ':'), indent=4) - f.close() - - - - def get_logger(self): return self._logger - def get_favorites_path(self): return self._FAVORITES_FILE - def get_app_paths(self): return self._APP_PATHS - def get_favorites(self): return self._faves diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 415301e..a8e5edd 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -1,6 +1,3 @@ """ Utils module """ - -from .Logger import Logger -from .Settings import Settings diff --git a/src/utils/debugging.py b/src/utils/debugging.py new file mode 100644 index 0000000..b84193a --- /dev/null +++ b/src/utils/debugging.py @@ -0,0 +1,52 @@ +# Python imports + +# Lib imports + +# Application imports + + + +# Break into a Python console upon SIGUSR1 (Linux) or SIGBREAK (Windows: +# CTRL+Pause/Break). To be included in all production code, just in case. +def debug_signal_handler(signal, frame): + del signal + del frame + + try: + import rpdb2 + logger.debug("\n\nStarting embedded RPDB2 debugger. Password is 'foobar'\n\n") + rpdb2.start_embedded_debugger("foobar", True, True) + rpdb2.setbreak(depth=1) + return + except StandardError: + ... + + try: + from rfoo.utils import rconsole + logger.debug("\n\nStarting embedded rconsole debugger...\n\n") + rconsole.spawn_server() + return + except StandardError as ex: + ... + + try: + from pudb import set_trace + logger.debug("\n\nStarting PuDB debugger...\n\n") + set_trace(paused = True) + return + except StandardError as ex: + ... + + try: + import pdb + logger.debug("\n\nStarting embedded PDB debugger...\n\n") + pdb.Pdb(skip=['gi.*']).set_trace() + return + except StandardError as ex: + ... + + try: + import code + code.interact() + except StandardError as ex: + logger.debug(f"{ex}, returning to normal program flow...") diff --git a/src/utils/event_system.py b/src/utils/event_system.py new file mode 100644 index 0000000..9d876cf --- /dev/null +++ b/src/utils/event_system.py @@ -0,0 +1,54 @@ +# Python imports +from collections import defaultdict + +# Lib imports + +# Application imports +from .singleton import Singleton + + + +class EventSystem(Singleton): + """ Create event system. """ + + def __init__(self): + self.subscribers = defaultdict(list) + + + def subscribe(self, event_type, fn): + self.subscribers[event_type].append(fn) + + def unsubscribe(self, event_type, fn): + self.subscribers[event_type].remove(fn) + + def unsubscribe_all(self, event_type): + self.subscribers.pop(event_type, None) + + def emit(self, event_type, data = None): + if event_type in self.subscribers: + for fn in self.subscribers[event_type]: + if data: + if hasattr(data, '__iter__') and not type(data) is str: + fn(*data) + else: + fn(data) + else: + fn() + + def emit_and_await(self, event_type, data = None): + """ NOTE: Should be used when signal has only one listener and vis-a-vis """ + if event_type in self.subscribers: + response = None + for fn in self.subscribers[event_type]: + if data: + if hasattr(data, '__iter__') and not type(data) is str: + response = fn(*data) + else: + response = fn(data) + else: + response = fn() + + if not response in (None, ''): + break + + return response diff --git a/src/utils/ipc_server.py b/src/utils/ipc_server.py new file mode 100644 index 0000000..5d19ccc --- /dev/null +++ b/src/utils/ipc_server.py @@ -0,0 +1,114 @@ +# Python imports +import os +import threading +import time +from multiprocessing.connection import Client +from multiprocessing.connection import Listener + +# Lib imports + +# Application imports +from .singleton import Singleton + + + +class IPCServer(Singleton): + """ Create a listener so that other {app_name} instances send requests back to existing instance. """ + def __init__(self, ipc_address: str = '127.0.0.1', conn_type: str = "socket"): + self.is_ipc_alive = False + self._ipc_port = 4848 + self._ipc_address = ipc_address + self._conn_type = conn_type + self._ipc_authkey = b'' + bytes(f'{app_name}-ipc', 'utf-8') + self._ipc_timeout = 15.0 + + if conn_type == "socket": + self._ipc_address = f'/tmp/{app_name}-ipc.sock' + elif conn_type == "full_network": + self._ipc_address = '0.0.0.0' + elif conn_type == "full_network_unsecured": + self._ipc_authkey = None + self._ipc_address = '0.0.0.0' + elif conn_type == "local_network_unsecured": + self._ipc_authkey = None + + self._subscribe_to_events() + + def _subscribe_to_events(self): + event_system.subscribe("post_file_to_ipc", self.send_ipc_message) + + + def create_ipc_listener(self) -> None: + if self._conn_type == "socket": + if os.path.exists(self._ipc_address) and settings_manager.is_dirty_start(): + os.unlink(self._ipc_address) + + listener = Listener(address=self._ipc_address, family="AF_UNIX", authkey=self._ipc_authkey) + elif "unsecured" not in self._conn_type: + listener = Listener((self._ipc_address, self._ipc_port), authkey=self._ipc_authkey) + else: + listener = Listener((self._ipc_address, self._ipc_port)) + + + self.is_ipc_alive = True + self._run_ipc_loop(listener) + + @daemon_threaded + def _run_ipc_loop(self, listener) -> None: + # NOTE: Not thread safe if using with Gtk. Need to import GLib and use idle_add + while True: + try: + conn = listener.accept() + start_time = time.perf_counter() + self._handle_ipc_message(conn, start_time) + except Exception as e: + ... + + listener.close() + + def _handle_ipc_message(self, conn, start_time) -> None: + while True: + msg = conn.recv() + if settings_manager.is_debug(): + print(msg) + + if "FILE|" in msg: + file = msg.split("FILE|")[1].strip() + if file: + event_system.emit("handle_file_from_ipc", file) + + if "DIR|" in msg: + file = msg.split("DIR|")[1].strip() + if file: + event_system.emit("handle_dir_from_ipc", file) + + conn.close() + break + + + if msg in ['close connection', 'close server']: + conn.close() + break + + # NOTE: Not perfect but insures we don't lock up the connection for too long. + end_time = time.perf_counter() + if (end_time - start_time) > self._ipc_timeout: + conn.close() + break + + + def send_ipc_message(self, message: str = "Empty Data...") -> None: + try: + if self._conn_type == "socket": + conn = Client(address=self._ipc_address, family="AF_UNIX", authkey=self._ipc_authkey) + elif "unsecured" not in self._conn_type: + conn = Client((self._ipc_address, self._ipc_port), authkey=self._ipc_authkey) + else: + conn = Client((self._ipc_address, self._ipc_port)) + + conn.send(message) + conn.close() + except ConnectionRefusedError as e: + print("Connection refused...") + except Exception as e: + print(repr(e)) diff --git a/src/utils/logger.py b/src/utils/logger.py new file mode 100644 index 0000000..10e93c4 --- /dev/null +++ b/src/utils/logger.py @@ -0,0 +1,61 @@ +# Python imports +import os +import logging + +# Lib imports + +# Application imports +from .singleton import Singleton + + + +class Logger(Singleton): + """ + Create a new logging object and return it. + :note: + NOSET # Don't know the actual log level of this... (defaulting or literally none?) + Log Levels (From least to most) + Type Value + CRITICAL 50 + ERROR 40 + WARNING 30 + INFO 20 + DEBUG 10 + :param loggerName: Sets the name of the logger object. (Used in log lines) + :param createFile: Whether we create a log file or just pump to terminal + + :return: the logging object we created + """ + + def __init__(self, config_path: str, _ch_log_lvl = logging.CRITICAL, _fh_log_lvl = logging.INFO): + self._CONFIG_PATH = config_path + self.global_lvl = logging.DEBUG # Keep this at highest so that handlers can filter to their desired levels + self.ch_log_lvl = _ch_log_lvl # Prety much the only one we ever change + self.fh_log_lvl = _fh_log_lvl + + def get_logger(self, loggerName: str = "NO_LOGGER_NAME_PASSED", createFile: bool = True) -> logging.Logger: + log = logging.getLogger(loggerName) + log.setLevel(self.global_lvl) + + # Set our log output styles + fFormatter = logging.Formatter('[%(asctime)s] %(pathname)s:%(lineno)d %(levelname)s - %(message)s', '%m-%d %H:%M:%S') + cFormatter = logging.Formatter('%(pathname)s:%(lineno)d] %(levelname)s - %(message)s') + + ch = logging.StreamHandler() + ch.setLevel(level=self.ch_log_lvl) + ch.setFormatter(cFormatter) + log.addHandler(ch) + + if createFile: + folder = self._CONFIG_PATH + file = f"{folder}/application.log" + + if not os.path.exists(folder): + os.mkdir(folder) + + fh = logging.FileHandler(file) + fh.setLevel(level=self.fh_log_lvl) + fh.setFormatter(fFormatter) + log.addHandler(fh) + + return log diff --git a/src/utils/settings_manager/__init__.py b/src/utils/settings_manager/__init__.py new file mode 100644 index 0000000..a0b3452 --- /dev/null +++ b/src/utils/settings_manager/__init__.py @@ -0,0 +1,4 @@ +""" + Settings module +""" +from .manager import SettingsManager diff --git a/src/utils/settings_manager/manager.py b/src/utils/settings_manager/manager.py new file mode 100644 index 0000000..d6972a3 --- /dev/null +++ b/src/utils/settings_manager/manager.py @@ -0,0 +1,74 @@ +# Python imports +import signal +import json +from os import path +from os import mkdir + +# Gtk imports + +# Application imports +from ..singleton import Singleton +from ..styles import Styles +from .options.settings import Settings + + +class MissingConfigError(Exception): + pass + + +class SettingsManager(Singleton): + def __init__(self): + self._SCRIPT_PTH = path.dirname(path.realpath(__file__)) + self._USER_HOME = path.expanduser('~') + self._USR_PATH = f"/usr/share/{app_name.lower()}" + + self._USR_CONFIG_FILE = f"{self._USR_PATH}/settings.json" + self._HOME_CONFIG_PATH = f"{self._USER_HOME}/.config/{app_name.lower()}" + self._CONFIG_FILE = f"{self._HOME_CONFIG_PATH}/settings.json" + + if not path.exists(self._HOME_CONFIG_PATH): + mkdir(self._HOME_CONFIG_PATH) + + + self.settings: Settings = None + self._main_window = None + + self._trace_debug = False + self._debug = False + self._styles = Styles() + + def set_main_window(self, window): self._main_window = window + + def get_home_path(self) -> str: return self._USER_HOME + def get_home_config_path(self) -> str: return self._HOME_CONFIG_PATH + def get_main_window(self): return self._main_window + def get_styles(self): return self._styles + def get_style(self): return self._styles + + def is_trace_debug(self) -> bool: return self._trace_debug + def is_debug(self) -> bool: return self._debug + + def set_trace_debug(self, trace_debug: bool): + self._trace_debug = trace_debug + + def set_debug(self, debug: bool): + self._debug = debug + + def call_method(self, target_class = None, _method_name = None, data = None): + method_name = str(_method_name) + method = getattr(target_class, method_name, lambda data: f"No valid key passed...\nkey={method_name}\nargs={data}") + return method(data) if data else method() + + def load_settings(self): + if not path.exists(self._CONFIG_FILE): + self.settings = Settings() + return + + with open(self._CONFIG_FILE) as file: + data = json.load(file) + data["load_defaults"] = False + self.settings = Settings(**data) + + def save_settings(self): + with open(self._CONFIG_FILE, 'w') as outfile: + json.dump(self.settings.as_dict(), outfile, separators=(',', ':'), indent=4) diff --git a/src/utils/settings_manager/options/__init__.py b/src/utils/settings_manager/options/__init__.py new file mode 100644 index 0000000..0046efd --- /dev/null +++ b/src/utils/settings_manager/options/__init__.py @@ -0,0 +1,8 @@ +""" + Options module +""" +from .settings import Settings +from .config import Config +from .filters import Filters +from .theming import Theming +from .debugging import Debugging diff --git a/src/utils/settings_manager/options/config.py b/src/utils/settings_manager/options/config.py new file mode 100644 index 0000000..f65d7d0 --- /dev/null +++ b/src/utils/settings_manager/options/config.py @@ -0,0 +1,31 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports + + +@dataclass +class Config: + thumbnailer_path: str = "ffmpegthumbnailer" + blender_thumbnailer_path: str = "" + mplayer_options: str = "-quiet -really-quiet -xy 1600 -geometry 50%:50%" + music_app: str = "deadbeef" + media_app: str = "mpv" + image_app: str = "mirage" + office_app: str = "libreoffice" + pdf_app: str = "evince" + code_app: str = "atom" + text_app: str = "mousepad" + terminal_app: str = "terminator" + file_manager_app: str = "solarfm" + container_icon_wh: list = field(default_factory=lambda: [128, 128]) + video_icon_wh: list = field(default_factory=lambda: [128, 64]) + sys_icon_wh: list = field(default_factory=lambda: [56, 56]) + steam_cdn_url: str = "https://steamcdn-a.akamaihd.net/steam/apps/" + remux_folder_max_disk_usage: str = "8589934592" + application_dirs: list = field(default_factory=lambda: [ + "/usr/share/applications", + f"{settings_manager.get_home_path()}/.local/share/applications" + ]) diff --git a/src/utils/settings_manager/options/debugging.py b/src/utils/settings_manager/options/debugging.py new file mode 100644 index 0000000..3fc605d --- /dev/null +++ b/src/utils/settings_manager/options/debugging.py @@ -0,0 +1,12 @@ +# Python imports +from dataclasses import dataclass + +# Lib imports + +# Application imports + + +@dataclass +class Debugging: + ch_log_lvl: int = 10 + fh_log_lvl: int = 20 diff --git a/src/utils/settings_manager/options/favorites.py b/src/utils/settings_manager/options/favorites.py new file mode 100644 index 0000000..f15418a --- /dev/null +++ b/src/utils/settings_manager/options/favorites.py @@ -0,0 +1,11 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports + + +@dataclass +class Favorites: + apps: list = field(default_factory=lambda: []) diff --git a/src/utils/settings_manager/options/filters.py b/src/utils/settings_manager/options/filters.py new file mode 100644 index 0000000..3fe8a7f --- /dev/null +++ b/src/utils/settings_manager/options/filters.py @@ -0,0 +1,36 @@ +# Python imports +from dataclasses import dataclass, field + +# Lib imports + +# Application imports + + +@dataclass(slots = True) +class Filters: + accessories: list = field(default_factory=lambda: [ + "Utility" + ]) + multimedia: list = field(default_factory=lambda: [ + "Video", + "Audio" + ]) + graphics: list = field(default_factory=lambda: [ + ]) + game: list = field(default_factory=lambda: [ + ]) + office: list = field(default_factory=lambda: [ + ]) + development: list = field(default_factory=lambda:[ + ]) + internet: list = field(default_factory=lambda: [ + "Network" + ]) + settings: list = field(default_factory=lambda: [ + ]) + system: list = field(default_factory=lambda: [ + ]) + wine: list = field(default_factory=lambda: [ + ]) + other: list = field(default_factory=lambda: [ + ]) diff --git a/src/utils/settings_manager/options/settings.py b/src/utils/settings_manager/options/settings.py new file mode 100644 index 0000000..5ec4b1f --- /dev/null +++ b/src/utils/settings_manager/options/settings.py @@ -0,0 +1,33 @@ +# Python imports +from dataclasses import dataclass, field +from dataclasses import asdict + +# Gtk imports + +# Application imports +from .config import Config +from .favorites import Favorites +from .filters import Filters +from .theming import Theming +from .debugging import Debugging + + +@dataclass +class Settings: + load_defaults: bool = True + config: Config = field(default_factory=lambda: Config()) + favorites: Favorites = field(default_factory=lambda: Favorites()) + filters: Filters = field(default_factory=lambda: Filters()) + theming: Theming = field(default_factory=lambda: Theming()) + debugging: Debugging = field(default_factory=lambda: Debugging()) + + def __post_init__(self): + if not self.load_defaults: + self.load_defaults = False + self.config = Config(**self.config) + self.filters = Filters(**self.filters) + self.theming = Theming(**self.theming) + self.debugging = Debugging(**self.debugging) + + def as_dict(self): + return asdict(self) diff --git a/src/utils/settings_manager/options/theming.py b/src/utils/settings_manager/options/theming.py new file mode 100644 index 0000000..034f7bd --- /dev/null +++ b/src/utils/settings_manager/options/theming.py @@ -0,0 +1,13 @@ +# Python imports +from dataclasses import dataclass + +# Lib imports + +# Application imports + + +@dataclass +class Theming: + success_color: str = "#88cc27" + warning_color: str = "#ffa800" + error_color: str = "#ff0000" diff --git a/src/utils/settings_manager/start_check_mixin.py b/src/utils/settings_manager/start_check_mixin.py new file mode 100644 index 0000000..688da36 --- /dev/null +++ b/src/utils/settings_manager/start_check_mixin.py @@ -0,0 +1,51 @@ +# Python imports +import os +import json +import inspect + +# Lib imports + +# Application imports + + + + +class StartCheckMixin: + def is_dirty_start(self) -> bool: return self._dirty_start + def clear_pid(self): self._clean_pid() + + def do_dirty_start_check(self): + if not os.path.exists(self._PID_FILE): + self._write_new_pid() + else: + with open(self._PID_FILE, "r") as _pid: + pid = _pid.readline().strip() + if pid not in ("", None): + self._check_alive_status(int(pid)) + else: + self._write_new_pid() + + """ Check For the existence of a unix pid. """ + def _check_alive_status(self, pid): + print(f"PID Found: {pid}") + try: + os.kill(pid, 0) + except OSError: + print(f"{app_name} is starting dirty...") + self._dirty_start = True + self._write_new_pid() + return + + print("PID is alive... Let downstream errors (sans debug args) handle app closure propigation.") + + def _write_new_pid(self): + pid = os.getpid() + self._write_pid(pid) + print(f"{app_name} PID: {pid}") + + def _clean_pid(self): + os.unlink(self._PID_FILE) + + def _write_pid(self, pid): + with open(self._PID_FILE, "w") as _pid: + _pid.write(f"{pid}") diff --git a/src/utils/singleton.py b/src/utils/singleton.py new file mode 100644 index 0000000..23b7191 --- /dev/null +++ b/src/utils/singleton.py @@ -0,0 +1,24 @@ +# Python imports + +# Lib imports + +# Application imports + + + +class SingletonError(Exception): + pass + + + +class Singleton: + ccount = 0 + + def __new__(cls, *args, **kwargs): + obj = super(Singleton, cls).__new__(cls) + cls.ccount += 1 + + if cls.ccount == 2: + raise SingletonError(f"Exceeded {cls.__name__} instantiation limit...") + + return obj diff --git a/src/signal_classes/mixins/StylesMixin.py b/src/utils/styles.py similarity index 90% rename from src/signal_classes/mixins/StylesMixin.py rename to src/utils/styles.py index 8f8d576..b592f7c 100644 --- a/src/signal_classes/mixins/StylesMixin.py +++ b/src/utils/styles.py @@ -1,15 +1,19 @@ # Python imports # Lib imports -from PyInquirer import style_from_dict, Token +from libs.PyInquirer import style_from_dict, Token # Application imports +from .singleton import Singleton -class StylesMixin: +class Styles(Singleton): + def __init__(self): + ... + """ - The StylesMixin has style methods that get called and + The Styles class has style methods that get called and return their respective objects. """