├── .travis.yml ├── LICENSE ├── README.rst ├── examples ├── asyncssh-example.py ├── simple-terminal.py ├── terminal-and-textarea.py ├── terminal-in-dialog.py └── two-terminals.py ├── mypy.ini ├── ptterm ├── __init__.py ├── backends │ ├── __init__.py │ ├── asyncssh.py │ ├── base.py │ ├── darwin.py │ ├── posix.py │ ├── posix_utils.py │ ├── win32.py │ └── win32_pipes.py ├── key_mappings.py ├── process.py ├── py.typed ├── screen.py ├── stream.py ├── terminal.py └── utils.py ├── pyproject.toml └── setup.py /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | cache: pip 3 | language: python 4 | 5 | matrix: 6 | include: 7 | - python: 3.7 8 | dist: xenial 9 | sudo: required 10 | - python: 3.6 11 | 12 | install: 13 | - pip install . flake8 isort black 14 | - pip list 15 | 16 | script: 17 | - echo "$TRAVIS_PYTHON_VERSION" 18 | - flake8 ptterm 19 | 20 | # Check wheather the imports were sorted correctly. 21 | # When this fails, please run ./tools/sort-imports.sh 22 | - isort -c -rc ptterm setup.py examples 23 | 24 | - black --check ptterm setup.py examples 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Jonathan Slenders 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, this 11 | list of conditions and the following disclaimer in the documentation and/or 12 | other materials provided with the distribution. 13 | 14 | * Neither the name of the {organization} nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ptterm 2 | ====== 3 | 4 | *A terminal emulator widget for prompt_toolkit applications* 5 | 6 | Features 7 | -------- 8 | 9 | - Cross platform: Windows + Linux support. 10 | 11 | 12 | Applications using ptterm 13 | ------------------------- 14 | 15 | - `pymux `_: A terminal multiplexer, 16 | written in Python. 17 | 18 | 19 | Example 20 | ------- 21 | 22 | Inserting the terminal into a prompt_toolkit application is as easy as 23 | importing a `Terminal` and inserting it into the layout. You can pass a 24 | `done_callback` to get notified when the terminal process is done. 25 | 26 | 27 | .. code:: python 28 | 29 | #!/usr/bin/env python 30 | from prompt_toolkit.application import Application 31 | from prompt_toolkit.layout import Layout 32 | from ptterm import Terminal 33 | 34 | 35 | def main(): 36 | def done(): 37 | application.exit() 38 | 39 | application = Application( 40 | layout=Layout( 41 | container=Terminal(done_callback=done) 42 | ), 43 | full_screen=True, 44 | ) 45 | application.run() 46 | 47 | 48 | if __name__ == '__main__': 49 | main() 50 | 51 | 52 | Thanks 53 | ------ 54 | 55 | - Thanks to `pyte `_: for implementing a 56 | vt100 emulator. 57 | - Thanks to `winpty Control-X to exit.' 53 | ) 54 | ), 55 | ), 56 | term, 57 | ] 58 | ), 59 | focused_element=term, 60 | ), 61 | style=style, 62 | key_bindings=kb, 63 | full_screen=True, 64 | mouse_support=True, 65 | ) 66 | await application.run_async() 67 | 68 | 69 | if __name__ == "__main__": 70 | asyncio.get_event_loop().run_until_complete(main()) 71 | -------------------------------------------------------------------------------- /examples/simple-terminal.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from prompt_toolkit.application import Application 3 | from prompt_toolkit.layout import Layout 4 | 5 | from ptterm import Terminal 6 | 7 | 8 | def main(): 9 | def done(): 10 | application.exit() 11 | 12 | application = Application( 13 | layout=Layout(container=Terminal(done_callback=done)), full_screen=True 14 | ) 15 | application.run() 16 | 17 | 18 | if __name__ == "__main__": 19 | main() 20 | -------------------------------------------------------------------------------- /examples/terminal-and-textarea.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from prompt_toolkit.application import Application 3 | from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings 4 | from prompt_toolkit.key_binding.defaults import load_key_bindings 5 | from prompt_toolkit.layout import HSplit, Layout, VSplit, Window 6 | from prompt_toolkit.layout.controls import FormattedTextControl 7 | from prompt_toolkit.styles import Style 8 | from prompt_toolkit.widgets import TextArea 9 | 10 | from ptterm import Terminal 11 | 12 | 13 | def main(): 14 | style = Style( 15 | [ 16 | ("terminal focused", "bg:#aaaaaa"), 17 | ("title", "bg:#000044 #ffffff underline"), 18 | ] 19 | ) 20 | 21 | term1 = Terminal() 22 | 23 | text_area = TextArea( 24 | text="Press Control-W to switch focus.\n" 25 | "Then you can edit this text area.\n" 26 | "Press Control-X to exit" 27 | ) 28 | 29 | kb = KeyBindings() 30 | 31 | @kb.add("c-w") 32 | def _(event): 33 | switch_focus() 34 | 35 | @kb.add("c-x", eager=True) 36 | def _(event): 37 | event.app.exit() 38 | 39 | def switch_focus(): 40 | "Change focus when Control-W is pressed." 41 | if application.layout.has_focus(term1): 42 | application.layout.focus(text_area) 43 | else: 44 | application.layout.focus(term1) 45 | 46 | application = Application( 47 | layout=Layout( 48 | container=HSplit( 49 | [ 50 | Window( 51 | height=1, 52 | style="class:title", 53 | content=FormattedTextControl( 54 | " Press Control-W to switch focus." 55 | ), 56 | ), 57 | VSplit( 58 | [ 59 | term1, 60 | Window(style="bg:#aaaaff", width=1), 61 | text_area, 62 | ] 63 | ), 64 | ] 65 | ), 66 | focused_element=term1, 67 | ), 68 | style=style, 69 | key_bindings=merge_key_bindings( 70 | [ 71 | load_key_bindings(), 72 | kb, 73 | ] 74 | ), 75 | full_screen=True, 76 | mouse_support=True, 77 | ) 78 | application.run() 79 | 80 | 81 | if __name__ == "__main__": 82 | main() 83 | -------------------------------------------------------------------------------- /examples/terminal-in-dialog.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from prompt_toolkit.application import Application 3 | from prompt_toolkit.layout import Layout 4 | from prompt_toolkit.layout.dimension import D 5 | from prompt_toolkit.widgets import Dialog 6 | 7 | from ptterm import Terminal 8 | 9 | 10 | def main(): 11 | def done(): 12 | application.exit() 13 | 14 | term = Terminal(width=D(preferred=60), height=D(preferred=25), done_callback=done) 15 | 16 | application = Application( 17 | layout=Layout( 18 | container=Dialog(title="Terminal demo", body=term, with_background=True), 19 | focused_element=term, 20 | ), 21 | full_screen=True, 22 | mouse_support=True, 23 | ) 24 | application.run() 25 | 26 | 27 | if __name__ == "__main__": 28 | main() 29 | -------------------------------------------------------------------------------- /examples/two-terminals.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from prompt_toolkit.application import Application 3 | from prompt_toolkit.formatted_text import HTML 4 | from prompt_toolkit.key_binding import KeyBindings 5 | from prompt_toolkit.layout import HSplit, Layout, VSplit, Window 6 | from prompt_toolkit.layout.controls import FormattedTextControl 7 | from prompt_toolkit.layout.dimension import D 8 | from prompt_toolkit.styles import Style 9 | 10 | from ptterm import Terminal 11 | 12 | 13 | def main(): 14 | style = Style( 15 | [ 16 | ("terminal not-focused", "#888888"), 17 | ("title", "bg:#000044 #ffffff underline"), 18 | ] 19 | ) 20 | 21 | done_count = [0] # nonlocal. 22 | 23 | def done(): 24 | done_count[0] += 1 25 | if done_count[0] == 2: 26 | application.exit() 27 | else: 28 | switch_focus() 29 | 30 | term1 = Terminal( 31 | width=D(preferred=80), 32 | height=D(preferred=40), 33 | style="class:terminal", 34 | done_callback=done, 35 | ) 36 | 37 | term2 = Terminal( 38 | width=D(preferred=80), 39 | height=D(preferred=40), 40 | style="class:terminal", 41 | done_callback=done, 42 | ) 43 | 44 | kb = KeyBindings() 45 | 46 | @kb.add("c-w") 47 | def _(event): 48 | switch_focus() 49 | 50 | def switch_focus(): 51 | "Change focus when Control-W is pressed." 52 | if application.layout.has_focus(term1): 53 | application.layout.focus(term2) 54 | else: 55 | application.layout.focus(term1) 56 | 57 | application = Application( 58 | layout=Layout( 59 | container=HSplit( 60 | [ 61 | Window( 62 | height=1, 63 | style="class:title", 64 | content=FormattedTextControl( 65 | HTML( 66 | ' Press Control-W to switch focus.' 67 | ) 68 | ), 69 | ), 70 | VSplit( 71 | [ 72 | term1, 73 | Window(style="bg:#aaaaff", width=1), 74 | term2, 75 | ] 76 | ), 77 | ] 78 | ), 79 | focused_element=term1, 80 | ), 81 | style=style, 82 | key_bindings=kb, 83 | full_screen=True, 84 | mouse_support=True, 85 | ) 86 | application.run() 87 | 88 | 89 | if __name__ == "__main__": 90 | main() 91 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = True 3 | no_implicit_optional = True 4 | platform = win32 5 | strict_equality = True 6 | strict_optional = True 7 | -------------------------------------------------------------------------------- /ptterm/__init__.py: -------------------------------------------------------------------------------- 1 | from .terminal import Terminal 2 | 3 | __all__ = [ 4 | "Terminal", 5 | ] 6 | -------------------------------------------------------------------------------- /ptterm/backends/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Backend 2 | 3 | __all__ = ["Backend"] 4 | -------------------------------------------------------------------------------- /ptterm/backends/asyncssh.py: -------------------------------------------------------------------------------- 1 | from asyncio import Future, get_event_loop 2 | from typing import Callable, List, Optional 3 | 4 | from asyncssh import SSHClientChannel, SSHClientConnection, SSHClientSession 5 | 6 | from .base import Backend 7 | 8 | __all__ = ["AsyncSSHBackend"] 9 | 10 | 11 | class AsyncSSHBackend(Backend): 12 | """ 13 | Display asyncssh client session. 14 | """ 15 | 16 | def __init__( 17 | self, 18 | ssh_client_connection: "SSHClientConnection", 19 | command: Optional[str] = None, 20 | ) -> None: 21 | self.ssh_client_connection = ssh_client_connection 22 | self.command = command 23 | 24 | self._channel: Optional[SSHClientChannel] = None 25 | self._session: Optional[SSHClientSession] = None 26 | 27 | self._reader_connected = False 28 | self._input_ready_callbacks: List[Callable[[], None]] = [] 29 | self._receive_buffer: List[str] = [] 30 | self.ready_f: Future[None] = Future() 31 | 32 | self.loop = get_event_loop() 33 | 34 | def start(self) -> None: 35 | class Session(SSHClientSession): 36 | def connection_made(_, chan): 37 | pass 38 | 39 | def connection_lost(_, exc): 40 | self.ready_f.set_result(None) 41 | 42 | def session_started(_): 43 | pass 44 | 45 | def data_received(_, data, datatype): 46 | send_signal = len(self._receive_buffer) == 0 47 | self._receive_buffer.append(data) 48 | 49 | if send_signal: 50 | for cb in self._input_ready_callbacks: 51 | cb() 52 | 53 | def exit_signal_received(self, signal, core_dumped, msg, lang): 54 | pass 55 | 56 | async def run() -> None: 57 | ( 58 | self._channel, 59 | self._session, 60 | ) = await self.ssh_client_connection.create_session( 61 | session_factory=lambda: Session(), 62 | command=self.command, 63 | request_pty=True, 64 | term_type="xterm", 65 | term_size=(24, 80), 66 | encoding="utf-8", 67 | ) 68 | 69 | self.loop.create_task(run()) 70 | 71 | def add_input_ready_callback(self, callback: Callable[[], None]) -> None: 72 | if not self._reader_connected: 73 | self._input_ready_callbacks.append(callback) 74 | 75 | def connect_reader(self) -> None: 76 | if self._channel: 77 | self._channel.resume_reading() 78 | 79 | @property 80 | def closed(self) -> bool: 81 | return False # TODO 82 | # return self._reader.closed 83 | 84 | def disconnect_reader(self) -> None: 85 | if self._channel is not None and self._reader_connected: 86 | self._channel.pause_reading() 87 | self._reader_connected = False 88 | 89 | def read_text(self, amount: int = 4096) -> str: 90 | result = "".join(self._receive_buffer) 91 | self._receive_buffer = [] 92 | return result 93 | 94 | def write_text(self, text: str) -> None: 95 | if self._channel: 96 | try: 97 | self._channel.write(text) 98 | except BrokenPipeError: 99 | return 100 | 101 | def write_bytes(self, data: bytes) -> None: 102 | raise NotImplementedError 103 | 104 | def set_size(self, width: int, height: int) -> None: 105 | """ 106 | Set terminal size. 107 | """ 108 | if self._channel: 109 | self._channel.change_terminal_size(width, height) 110 | 111 | def kill(self) -> None: 112 | "Terminate process." 113 | if self._channel: 114 | # self._channel.kill() 115 | self._channel.terminate() 116 | 117 | def send_signal(self, signal: int) -> None: 118 | "Send signal to running process." 119 | if self._channel: 120 | self._channel.send_signal(signal) 121 | 122 | def get_name(self) -> str: 123 | "Return the process name." 124 | if self._channel: 125 | command = self._channel.get_command() 126 | return f"asyncssh: {command}" 127 | return "" 128 | 129 | def get_cwd(self) -> Optional[str]: 130 | if self._channel: 131 | return self._channel.getcwd() 132 | return None 133 | -------------------------------------------------------------------------------- /ptterm/backends/base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | __all__ = ["Backend"] 4 | 5 | 6 | class Backend(metaclass=abc.ABCMeta): 7 | """ 8 | Base class for the terminal backend-interface. 9 | """ 10 | 11 | def add_input_ready_callback(self, callback): 12 | """ 13 | Add a new callback to be called for when there's input ready to read. 14 | """ 15 | 16 | @abc.abstractmethod 17 | def kill(self): 18 | """ 19 | Terminate the sub process. 20 | """ 21 | 22 | @abc.abstractproperty 23 | def closed(self): 24 | """ 25 | Return `True` if this is closed. 26 | """ 27 | 28 | @abc.abstractmethod 29 | def read_text(self, amount): 30 | """ 31 | Read terminal output and return it. 32 | """ 33 | 34 | @abc.abstractmethod 35 | def write_text(self, text): 36 | """ 37 | Write text to the stdin of the process. 38 | """ 39 | 40 | @abc.abstractmethod 41 | def connect_reader(self): 42 | """ 43 | Connect the reader to the event loop. 44 | """ 45 | 46 | @abc.abstractmethod 47 | def disconnect_reader(self): 48 | """ 49 | Connect the reader to the event loop. 50 | """ 51 | 52 | @abc.abstractmethod 53 | def set_size(self, width, height): 54 | """ 55 | Set terminal size. 56 | """ 57 | 58 | @abc.abstractmethod 59 | def start(self): 60 | """ 61 | Start the terminal process. 62 | """ 63 | 64 | @abc.abstractmethod 65 | def get_name(self): 66 | """ 67 | Return the name for this process, or `None` when unknown. 68 | """ 69 | 70 | @abc.abstractmethod 71 | def get_cwd(self): 72 | """ 73 | Return the current working directory of the process running in this 74 | terminal. 75 | """ 76 | -------------------------------------------------------------------------------- /ptterm/backends/darwin.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tools for Darwin. (Mac OS X.) 3 | """ 4 | from ctypes import c_ubyte, c_uint, c_ulong, cdll, pointer 5 | 6 | __all__ = ["get_proc_info", "get_proc_name"] 7 | 8 | # Current Values as of El Capitan 9 | 10 | # /usr/include/sys/sysctl.h 11 | CTL_KERN = 1 12 | KERN_PROC = 14 13 | KERN_PROC_PID = 1 14 | KERN_PROC_PGRP = 2 15 | 16 | # /usr/include/sys/param.h 17 | MAXCOMLEN = 16 18 | 19 | # kinfo_proc (/usr/include/sys/sysctl.h) 20 | # \-> extern_proc (/usr/include/sys/proc.h) 21 | P_COMM_OFFSET = 243 22 | 23 | # Finding the type for PIDs was *interesting* 24 | # pid_t in /usr/include/sys/types.h -> \ 25 | # pid_t in /usr/include/sys/_types/_pid_t.h -> \ 26 | # __darwin_pid_t -> /usr/include/sys/_types.h -> \ 27 | # __int32_t 28 | 29 | LIBC = None 30 | 31 | 32 | def _init(): 33 | """ 34 | Initialize ctypes DLL link. 35 | """ 36 | global LIBC 37 | 38 | if LIBC is None: 39 | try: 40 | LIBC = cdll.LoadLibrary("libc.dylib") 41 | except OSError: 42 | # On OS X El Capitan, the above doesn't work for some reason and we 43 | # have to explicitly mention the path. 44 | # See: https://github.com/ffi/ffi/issues/461 45 | LIBC = cdll.LoadLibrary("/usr/lib/libc.dylib") 46 | 47 | 48 | def get_proc_info(pid): 49 | """ 50 | Use sysctl to retrieve process info. 51 | """ 52 | # Ensure that we have the DLL loaded. 53 | _init() 54 | 55 | # Request the length of the process data. 56 | mib = (c_uint * 4)(CTL_KERN, KERN_PROC, KERN_PROC_PID, pid) 57 | oldlen = c_ulong() 58 | oldlenp = pointer(oldlen) 59 | r = LIBC.sysctl(mib, len(mib), None, oldlenp, None, 0) 60 | if r: 61 | return 62 | 63 | # Request the process data. 64 | reslen = oldlen.value 65 | old = (c_ubyte * reslen)() 66 | r = LIBC.sysctl(mib, len(mib), old, oldlenp, None, 0) 67 | if r: 68 | return 69 | # assert oldlen.value <= reslen 70 | 71 | return old[:reslen] 72 | 73 | 74 | def get_proc_name(pid): 75 | """ 76 | Use sysctl to retrieve process name. 77 | """ 78 | proc_kinfo = get_proc_info(pid) 79 | if not proc_kinfo: 80 | return 81 | 82 | p_comm_range = proc_kinfo[P_COMM_OFFSET : P_COMM_OFFSET + MAXCOMLEN + 1] 83 | p_comm_raw = "".join(chr(c) for c in p_comm_range) 84 | p_comm = p_comm_raw.split("\0", 1)[0] 85 | 86 | return p_comm 87 | -------------------------------------------------------------------------------- /ptterm/backends/posix.py: -------------------------------------------------------------------------------- 1 | import os 2 | import resource 3 | import signal 4 | import sys 5 | import time 6 | import traceback 7 | from asyncio import Future, get_event_loop 8 | 9 | from prompt_toolkit.input.posix_utils import PosixStdinReader 10 | 11 | from .base import Backend 12 | from .posix_utils import pty_make_controlling_tty, set_terminal_size 13 | 14 | __all__ = ["PosixBackend"] 15 | 16 | 17 | class PosixBackend(Backend): 18 | def __init__(self, exec_func): 19 | self.exec_func = exec_func 20 | 21 | # Create pseudo terminal for this pane. 22 | self.master, self.slave = os.openpty() 23 | 24 | # Master side -> attached to terminal emulator. 25 | self._reader = PosixStdinReader(self.master, errors="replace") 26 | self._reader_connected = False 27 | self._input_ready_callbacks = [] 28 | 29 | self.ready_f = Future() 30 | self.loop = get_event_loop() 31 | self.pid = None 32 | 33 | def add_input_ready_callback(self, callback): 34 | self._input_ready_callbacks.append(callback) 35 | 36 | @classmethod 37 | def from_command(cls, command, before_exec_func=None): 38 | """ 39 | Create Process from command, 40 | e.g. command=['python', '-c', 'print("test")'] 41 | 42 | :param before_exec_func: Function that is called before `exec` in the 43 | process fork. 44 | """ 45 | assert isinstance(command, list) 46 | assert before_exec_func is None or callable(before_exec_func) 47 | 48 | def execv(): 49 | if before_exec_func: 50 | before_exec_func() 51 | 52 | for p in os.environ["PATH"].split(":"): 53 | path = os.path.join(p, command[0]) 54 | if os.path.exists(path) and os.access(path, os.X_OK): 55 | os.execv(path, command) 56 | 57 | return cls(execv) 58 | 59 | def connect_reader(self): 60 | if self.master is not None and not self._reader_connected: 61 | 62 | def ready(): 63 | for cb in self._input_ready_callbacks: 64 | cb() 65 | 66 | self.loop.add_reader(self.master, ready) 67 | self._reader_connected = True 68 | 69 | @property 70 | def closed(self): 71 | return self._reader.closed 72 | 73 | def disconnect_reader(self): 74 | if self.master is not None and self._reader_connected: 75 | self.loop.remove_reader(self.master) 76 | self._reader_connected = False 77 | 78 | def read_text(self, amount=4096): 79 | return self._reader.read(amount) 80 | 81 | def write_text(self, text): 82 | self.write_bytes(text.encode("utf-8")) 83 | 84 | def write_bytes(self, data): 85 | while self.master is not None: 86 | try: 87 | os.write(self.master, data) 88 | except OSError as e: 89 | # This happens when the window resizes and a SIGWINCH was received. 90 | # We get 'Error: [Errno 4] Interrupted system call' 91 | if e.errno == 4: 92 | continue 93 | return 94 | 95 | def set_size(self, width, height): 96 | """ 97 | Set terminal size. 98 | """ 99 | assert isinstance(width, int) 100 | assert isinstance(height, int) 101 | 102 | if self.master is not None: 103 | set_terminal_size(self.master, height, width) 104 | 105 | def start(self): 106 | """ 107 | Create fork and start the child process. 108 | """ 109 | pid = os.fork() 110 | 111 | if pid == 0: 112 | self._in_child() 113 | elif pid > 0: 114 | # We wait a very short while, to be sure the child had the time to 115 | # call _exec. (Otherwise, we are still sharing signal handlers and 116 | # FDs.) Resizing the pty, when the child is still in our Python 117 | # code and has the signal handler from prompt_toolkit, but closed 118 | # the 'fd' for 'call_from_executor', will cause OSError. 119 | time.sleep(0.1) 120 | 121 | self.pid = pid 122 | 123 | # Wait for the process to finish. 124 | self._waitpid() 125 | 126 | def kill(self): 127 | "Terminate process." 128 | self.send_signal(signal.SIGKILL) 129 | 130 | def send_signal(self, signal): 131 | "Send signal to running process." 132 | assert isinstance(signal, int), type(signal) 133 | 134 | if self.pid and not self.closed: 135 | try: 136 | os.kill(self.pid, signal) 137 | except OSError: 138 | pass # [Errno 3] No such process. 139 | 140 | def _in_child(self): 141 | "Will be executed in the forked child." 142 | os.close(self.master) 143 | 144 | # Remove signal handler for SIGWINCH as early as possible. 145 | # (We don't want this to be triggered when execv has not been called 146 | # yet.) 147 | signal.signal(signal.SIGWINCH, 0) 148 | 149 | pty_make_controlling_tty(self.slave) 150 | 151 | # In the fork, set the stdin/out/err to our slave pty. 152 | os.dup2(self.slave, 0) 153 | os.dup2(self.slave, 1) 154 | os.dup2(self.slave, 2) 155 | 156 | # Execute in child. 157 | try: 158 | self._close_file_descriptors() 159 | self.exec_func() 160 | except Exception: 161 | traceback.print_exc() 162 | time.sleep(5) 163 | 164 | os._exit(1) 165 | os._exit(0) 166 | 167 | def _close_file_descriptors(self): 168 | # Do not allow child to inherit open file descriptors from parent. 169 | # (In case that we keep running Python code. We shouldn't close them. 170 | # because the garbage collector is still active, and he will close them 171 | # eventually.) 172 | max_fd = resource.getrlimit(resource.RLIMIT_NOFILE)[-1] 173 | 174 | try: 175 | os.closerange(3, max_fd) 176 | except OverflowError: 177 | # On OS X, max_fd can return very big values, than closerange 178 | # doesn't understand, e.g. 9223372036854775807. In this case, just 179 | # use 4096. This is what Linux systems report, and should be 180 | # sufficient. (I hope...) 181 | os.closerange(3, 4096) 182 | 183 | def _waitpid(self): 184 | """ 185 | Create an executor that waits and handles process termination. 186 | """ 187 | 188 | def wait_for_finished(): 189 | "Wait for PID in executor." 190 | os.waitpid(self.pid, 0) 191 | self.loop.call_soon(done) 192 | 193 | def done(): 194 | "PID received. Back in the main thread." 195 | # Close pty and remove reader. 196 | 197 | self.disconnect_reader() 198 | os.close(self.master) 199 | os.close(self.slave) 200 | 201 | self.master = None 202 | 203 | # Callback. 204 | self.ready_f.set_result(None) 205 | 206 | self.loop.run_in_executor(None, wait_for_finished) 207 | 208 | def get_name(self): 209 | "Return the process name." 210 | result = "" 211 | 212 | # Apparently, on a Linux system (like my Fedora box), I have to call 213 | # `tcgetpgrp` on the `master` fd. However, on te Window subsystem for 214 | # Linux, we have to use the `slave` fd. 215 | 216 | if self.master is not None: 217 | result = get_name_for_fd(self.master) 218 | 219 | if not result and self.slave is not None: 220 | result = get_name_for_fd(self.slave) 221 | 222 | return result 223 | 224 | def get_cwd(self): 225 | if self.pid: 226 | return get_cwd_for_pid(self.pid) 227 | 228 | 229 | if sys.platform in ("linux", "linux2", "cygwin"): 230 | 231 | def get_name_for_fd(fd): 232 | """ 233 | Return the process name for a given process ID. 234 | 235 | :param fd: Slave file descriptor. (Often the master fd works as well, 236 | but apparentsly on WSL only the slave FD works.) 237 | """ 238 | try: 239 | pgrp = os.tcgetpgrp(fd) 240 | except OSError: 241 | # See: https://github.com/jonathanslenders/pymux/issues/46 242 | return 243 | 244 | try: 245 | with open("/proc/%s/cmdline" % pgrp, "rb") as f: 246 | return f.read().decode("utf-8", "ignore").partition("\0")[0] 247 | except OSError: 248 | pass 249 | 250 | elif sys.platform == "darwin": 251 | from .darwin import get_proc_name 252 | 253 | def get_name_for_fd(fd): 254 | """ 255 | Return the process name for a given process ID. 256 | 257 | NOTE: on Linux, this seems to require the master FD. 258 | """ 259 | try: 260 | pgrp = os.tcgetpgrp(fd) 261 | except OSError: 262 | return 263 | 264 | try: 265 | return get_proc_name(pgrp) 266 | except OSError: 267 | pass 268 | 269 | else: 270 | 271 | def get_name_for_fd(fd): 272 | """ 273 | Return the process name for a given process ID. 274 | """ 275 | return 276 | 277 | 278 | def get_cwd_for_pid(pid): 279 | """ 280 | Return the current working directory for a given process ID. 281 | """ 282 | if sys.platform in ("linux", "linux2", "cygwin"): 283 | try: 284 | return os.readlink("/proc/%s/cwd" % pid) 285 | except OSError: 286 | pass 287 | -------------------------------------------------------------------------------- /ptterm/backends/posix_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Some utilities. 3 | """ 4 | import array 5 | import fcntl 6 | import os 7 | import termios 8 | 9 | __all__ = ( 10 | "pty_make_controlling_tty", 11 | "set_terminal_size", 12 | "nonblocking", 13 | ) 14 | 15 | 16 | def pty_make_controlling_tty(tty_fd): 17 | """ 18 | This makes the pseudo-terminal the controlling tty. This should be 19 | more portable than the pty.fork() function. Specifically, this should 20 | work on Solaris. 21 | 22 | Thanks to pexpect: 23 | http://pexpect.sourceforge.net/pexpect.html 24 | """ 25 | child_name = os.ttyname(tty_fd) 26 | 27 | # Disconnect from controlling tty. Harmless if not already connected. 28 | try: 29 | fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY) 30 | if fd >= 0: 31 | os.close(fd) 32 | # which exception, shouldnt' we catch explicitly .. ? 33 | except: 34 | # Already disconnected. This happens if running inside cron. 35 | pass 36 | 37 | os.setsid() 38 | 39 | # Verify we are disconnected from controlling tty 40 | # by attempting to open it again. 41 | try: 42 | fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY) 43 | if fd >= 0: 44 | os.close(fd) 45 | raise Exception( 46 | "Failed to disconnect from controlling " 47 | "tty. It is still possible to open /dev/tty." 48 | ) 49 | # which exception, shouldnt' we catch explicitly .. ? 50 | except: 51 | # Good! We are disconnected from a controlling tty. 52 | pass 53 | 54 | # Verify we can open child pty. 55 | fd = os.open(child_name, os.O_RDWR) 56 | if fd < 0: 57 | raise Exception("Could not open child pty, " + child_name) 58 | else: 59 | os.close(fd) 60 | 61 | # Verify we now have a controlling tty. 62 | if os.name != "posix": 63 | # Skip this on BSD-like systems since it will break. 64 | fd = os.open("/dev/tty", os.O_WRONLY) 65 | if fd < 0: 66 | raise Exception("Could not open controlling tty, /dev/tty") 67 | else: 68 | os.close(fd) 69 | 70 | 71 | def set_terminal_size(stdout_fileno, rows, cols): 72 | """ 73 | Set terminal size. 74 | 75 | (This is also mainly for internal use. Setting the terminal size 76 | automatically happens when the window resizes. However, sometimes the 77 | process that created a pseudo terminal, and the process that's attached to 78 | the output window are not the same, e.g. in case of a telnet connection, or 79 | unix domain socket, and then we have to sync the sizes by hand.) 80 | """ 81 | # Buffer for the C call 82 | # (The first parameter of 'array.array' needs to be 'str' on both Python 2 83 | # and Python 3.) 84 | buf = array.array("h", [rows, cols, 0, 0]) 85 | 86 | # Do: TIOCSWINSZ (Set) 87 | fcntl.ioctl(stdout_fileno, termios.TIOCSWINSZ, buf) 88 | 89 | 90 | class nonblocking: 91 | """ 92 | Make fd non blocking. 93 | """ 94 | 95 | def __init__(self, fd): 96 | self.fd = fd 97 | 98 | def __enter__(self): 99 | self.orig_fl = fcntl.fcntl(self.fd, fcntl.F_GETFL) 100 | fcntl.fcntl(self.fd, fcntl.F_SETFL, self.orig_fl | os.O_NONBLOCK) 101 | 102 | def __exit__(self, *args): 103 | fcntl.fcntl(self.fd, fcntl.F_SETFL, self.orig_fl) 104 | -------------------------------------------------------------------------------- /ptterm/backends/win32.py: -------------------------------------------------------------------------------- 1 | from prompt_toolkit.eventloop.future import Future 2 | from yawinpty import Pty, SpawnConfig 3 | 4 | from .base import Backend 5 | from .win32_pipes import PipeReader, PipeWriter 6 | 7 | __all__ = [ 8 | "Win32Backend", 9 | ] 10 | 11 | 12 | class Win32Backend(Backend): 13 | """ 14 | Terminal backend for Windows, on top of winpty. 15 | """ 16 | 17 | def __init__(self): 18 | self.pty = Pty() 19 | self.ready_f = Future() 20 | self._input_ready_callbacks = [] 21 | 22 | # Open input/output pipes. 23 | def received_data(data): 24 | self._buffer.append(data) 25 | for cb in self._input_ready_callbacks: 26 | cb() 27 | 28 | self.stdout_pipe_reader = PipeReader( 29 | self.pty.conout_name(), 30 | read_callback=received_data, 31 | done_callback=lambda: self.ready_f.set_result(None), 32 | ) 33 | 34 | self.stdin_pipe_writer = PipeWriter(self.pty.conin_name()) 35 | 36 | # Buffer in which we read + reading flag. 37 | self._buffer = [] 38 | 39 | def add_input_ready_callback(self, callback): 40 | """ 41 | Add a new callback to be called for when there's input ready to read. 42 | """ 43 | self._input_ready_callbacks.append(callback) 44 | if self._buffer: 45 | callback() 46 | 47 | def read_text(self, amount): 48 | "Read terminal output and return it." 49 | result = "".join(self._buffer) 50 | self._buffer = [] 51 | return result 52 | 53 | def write_text(self, text): 54 | "Write text to the stdin of the process." 55 | self.stdin_pipe_writer.write(text) 56 | 57 | def connect_reader(self): 58 | """ 59 | Connect the reader to the event loop. 60 | """ 61 | self.stdout_pipe_reader.start_reading() 62 | 63 | def disconnect_reader(self): 64 | """ 65 | Connect the reader to the event loop. 66 | """ 67 | self.stdout_pipe_reader.stop_reading() 68 | 69 | @property 70 | def closed(self): 71 | return self.ready_f.done() 72 | 73 | def set_size(self, width, height): 74 | "Set terminal size." 75 | self.pty.set_size(width, height) 76 | 77 | def start(self): 78 | """ 79 | Start the terminal process. 80 | """ 81 | self.pty.spawn( 82 | SpawnConfig( 83 | SpawnConfig.flag.auto_shutdown, cmdline=r"C:\windows\system32\cmd.exe" 84 | ) 85 | ) 86 | 87 | def kill(self): 88 | "Terminate the process." 89 | self.pty.close() 90 | 91 | def get_name(self): 92 | """ 93 | Return the name for this process, or `None` when unknown. 94 | """ 95 | return "cmd.exe" 96 | 97 | def get_cwd(self): 98 | return 99 | -------------------------------------------------------------------------------- /ptterm/backends/win32_pipes.py: -------------------------------------------------------------------------------- 1 | """ 2 | Abstractions on top of Win32 pipes for integration in the prompt_toolkit event 3 | loop. 4 | """ 5 | import ctypes 6 | from asyncio import Event, Future, ensure_future, get_event_loop 7 | from ctypes import ( 8 | POINTER, 9 | Structure, 10 | Union, 11 | c_void_p, 12 | py_object, 13 | windll, 14 | ) 15 | from ctypes.wintypes import BOOL, DWORD, HANDLE, ULONG 16 | 17 | __all__ = [ 18 | "PipeReader", 19 | "PipeWriter", 20 | ] 21 | 22 | INVALID_HANDLE_VALUE = -1 23 | GENERIC_READ = 0x80000000 24 | GENERIC_WRITE = 0x40000000 25 | OPEN_EXISTING = 3 26 | FILE_FLAG_OVERLAPPED = 0x40000000 27 | ERROR_IO_PENDING = 997 28 | ERROR_BROKEN_PIPE = 109 29 | 30 | 31 | class _US(Structure): 32 | _fields_ = [ 33 | ("Offset", DWORD), 34 | ("OffsetHigh", DWORD), 35 | ] 36 | 37 | 38 | class _U(Union): 39 | _fields_ = [ 40 | ("s", _US), 41 | ("Pointer", c_void_p), 42 | ] 43 | 44 | _anonymous_ = ("s",) 45 | 46 | 47 | class OVERLAPPED(Structure): 48 | _fields_ = [ 49 | ("Internal", POINTER(ULONG)), 50 | ("InternalHigh", POINTER(ULONG)), 51 | ("u", _U), 52 | ("hEvent", HANDLE), 53 | # Custom fields. 54 | ("channel", py_object), 55 | ] 56 | 57 | _anonymous_ = ("u",) 58 | 59 | 60 | class PipeReader: 61 | """ 62 | Asynchronous reader for win32 pipes. 63 | """ 64 | 65 | def __init__(self, pipe_name, read_callback, done_callback): 66 | self.pipe_name = pipe_name 67 | self.read_callback = read_callback 68 | self.done_callback = done_callback 69 | self.done = False 70 | 71 | self.handle = windll.kernel32.CreateFileW( 72 | pipe_name, GENERIC_READ, 0, None, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, None 73 | ) 74 | 75 | if self.handle == INVALID_HANDLE_VALUE: 76 | error_code = windll.kernel32.GetLastError() 77 | raise Exception("Invalid pipe handle. Error code=%r." % error_code) 78 | 79 | # Create overlapped structure and event. 80 | self._overlapped = OVERLAPPED() 81 | self._event = windll.kernel32.CreateEventA( 82 | None, # Default security attributes. 83 | BOOL(True), # Manual reset event. 84 | BOOL(True), # initial state = signaled. 85 | None, # Unnamed event object. 86 | ) 87 | self._overlapped.hEvent = self._event 88 | 89 | self._reading = Event() 90 | 91 | # Start reader coroutine. 92 | ensure_future(self._async_reader()) 93 | 94 | def _wait_for_event(self): 95 | """ 96 | Wraps a win32 event into a `Future` and wait for it. 97 | """ 98 | f = Future() 99 | 100 | def ready() -> None: 101 | get_event_loop().remove_win32_handle(self._event) 102 | f.set_result(None) 103 | 104 | get_event_loop().add_win32_handle(self._event, ready) 105 | 106 | return f 107 | 108 | async def _async_reader(self): 109 | buffer_size = 65536 110 | c_read = DWORD() 111 | buffer = ctypes.create_string_buffer(buffer_size + 1) 112 | 113 | while True: 114 | # Wait until `start_reading` is called. 115 | await self._reading.wait() 116 | 117 | # Call read. 118 | success = windll.kernel32.ReadFile( 119 | self.handle, 120 | buffer, 121 | DWORD(buffer_size), 122 | ctypes.byref(c_read), 123 | ctypes.byref(self._overlapped), 124 | ) 125 | 126 | if success: 127 | buffer[c_read.value] = b"\0" 128 | self.read_callback(buffer.value.decode("utf-8", "ignore")) 129 | 130 | else: 131 | error_code = windll.kernel32.GetLastError() 132 | # Pending I/O. Wait for it to finish. 133 | if error_code == ERROR_IO_PENDING: 134 | # Wait for event. 135 | await self._wait_for_event() 136 | 137 | # Get pending data. 138 | success = windll.kernel32.GetOverlappedResult( 139 | self.handle, 140 | ctypes.byref(self._overlapped), 141 | ctypes.byref(c_read), 142 | BOOL(False), 143 | ) 144 | 145 | if success: 146 | buffer[c_read.value] = b"\0" 147 | self.read_callback(buffer.value.decode("utf-8", "ignore")) 148 | 149 | elif error_code == ERROR_BROKEN_PIPE: 150 | self.stop_reading() 151 | self.done_callback() 152 | self.done = False 153 | return 154 | 155 | def start_reading(self): 156 | self._reading.set() 157 | 158 | def stop_reading(self): 159 | self._reading.clear() 160 | 161 | 162 | class PipeWriter: 163 | """ 164 | Wrapper around a win32 pipe. 165 | """ 166 | 167 | def __init__(self, pipe_name): 168 | self.pipe_name = pipe_name 169 | 170 | self.handle = windll.kernel32.CreateFileW( 171 | pipe_name, GENERIC_WRITE, 0, None, OPEN_EXISTING, 0, None 172 | ) 173 | 174 | if self.handle == INVALID_HANDLE_VALUE: 175 | error_code = windll.kernel32.GetLastError() 176 | raise Exception("Invalid stdin handle code=%r" % error_code) 177 | 178 | def write(self, text): 179 | "Write text to the stdin of the process." 180 | data = text.encode("utf-8") 181 | c_written = DWORD() 182 | 183 | windll.kernel32.WriteFile( 184 | self.handle, 185 | ctypes.create_string_buffer(data), 186 | len(data), 187 | ctypes.byref(c_written), 188 | None, 189 | ) 190 | 191 | # TODO: check 'written'. 192 | -------------------------------------------------------------------------------- /ptterm/key_mappings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Mapping between vt100 key sequences, the prompt_toolkit key constants and the 3 | Pymux namings. (Those namings are kept compatible with tmux.) 4 | """ 5 | from typing import Dict, Tuple 6 | 7 | from prompt_toolkit.input.vt100_parser import ANSI_SEQUENCES 8 | from prompt_toolkit.keys import Keys 9 | 10 | __all__ = ( 11 | "pymux_key_to_prompt_toolkit_key_sequence", 12 | "prompt_toolkit_key_to_vt100_key", 13 | "PYMUX_TO_PROMPT_TOOLKIT_KEYS", 14 | ) 15 | 16 | 17 | def pymux_key_to_prompt_toolkit_key_sequence(key: str) -> Tuple[str, ...]: 18 | """ 19 | Turn a pymux description of a key. E.g. "C-a" or "M-x" into a 20 | prompt-toolkit key sequence. 21 | 22 | Raises `ValueError` if the key is not known. 23 | """ 24 | # Make the c- and m- prefixes case insensitive. 25 | if key.lower().startswith("m-c-"): 26 | key = "M-C-" + key[4:] 27 | elif key.lower().startswith("c-"): 28 | key = "C-" + key[2:] 29 | elif key.lower().startswith("m-"): 30 | key = "M-" + key[2:] 31 | 32 | # Lookup key. 33 | try: 34 | return PYMUX_TO_PROMPT_TOOLKIT_KEYS[key] 35 | except KeyError: 36 | if len(key) == 1: 37 | return (key,) 38 | else: 39 | raise ValueError(f"Unknown key: {key!r}") 40 | 41 | 42 | # Create a mapping from prompt_toolkit keys to their ANSI sequences. 43 | # TODO: This is not completely correct yet. It doesn't take 44 | # cursor/application mode into account. Create new tables for this. 45 | def _keys_to_data() -> Dict[Keys, str]: 46 | result = {} 47 | for vt100_data, key in ANSI_SEQUENCES.items(): 48 | if not isinstance(key, tuple): 49 | if key not in result: 50 | # Only add key/data pair if it's not already here. 51 | # (The first sequence gets priority, otherwise c-m will always 52 | # resolve to "\x1b[27;6;13~" for instance, rather than "\r".) 53 | result[key] = vt100_data 54 | return result 55 | 56 | 57 | _PROMPT_TOOLKIT_KEY_TO_VT100 = _keys_to_data() 58 | 59 | 60 | def prompt_toolkit_key_to_vt100_key(key: str, application_mode: bool = False) -> str: 61 | """ 62 | Turn a prompt toolkit key. (E.g Keys.ControlB) into a Vt100 key sequence. 63 | (E.g. \x1b[A.) 64 | """ 65 | application_mode_keys: Dict[str, str] = { 66 | Keys.Up: "\x1bOA", 67 | Keys.Left: "\x1bOD", 68 | Keys.Right: "\x1bOC", 69 | Keys.Down: "\x1bOB", 70 | } 71 | 72 | if application_mode: 73 | try: 74 | return application_mode_keys[key] 75 | except KeyError: 76 | pass 77 | 78 | return _PROMPT_TOOLKIT_KEY_TO_VT100.get(key, key) 79 | 80 | 81 | PYMUX_TO_PROMPT_TOOLKIT_KEYS: Dict[str, Tuple[str, ...]] = { 82 | "Space": (" ",), 83 | "C-a": (Keys.ControlA,), 84 | "C-b": (Keys.ControlB,), 85 | "C-c": (Keys.ControlC,), 86 | "C-d": (Keys.ControlD,), 87 | "C-e": (Keys.ControlE,), 88 | "C-f": (Keys.ControlF,), 89 | "C-g": (Keys.ControlG,), 90 | "C-h": (Keys.ControlH,), 91 | "C-i": (Keys.ControlI,), 92 | "C-j": (Keys.ControlJ,), 93 | "C-k": (Keys.ControlK,), 94 | "C-l": (Keys.ControlL,), 95 | "C-m": (Keys.ControlM,), 96 | "C-n": (Keys.ControlN,), 97 | "C-o": (Keys.ControlO,), 98 | "C-p": (Keys.ControlP,), 99 | "C-q": (Keys.ControlQ,), 100 | "C-r": (Keys.ControlR,), 101 | "C-s": (Keys.ControlS,), 102 | "C-t": (Keys.ControlT,), 103 | "C-u": (Keys.ControlU,), 104 | "C-v": (Keys.ControlV,), 105 | "C-w": (Keys.ControlW,), 106 | "C-x": (Keys.ControlX,), 107 | "C-y": (Keys.ControlY,), 108 | "C-z": (Keys.ControlZ,), 109 | "C-Left": (Keys.ControlLeft,), 110 | "C-Right": (Keys.ControlRight,), 111 | "C-Up": (Keys.ControlUp,), 112 | "C-Down": (Keys.ControlDown,), 113 | "C-\\": (Keys.ControlBackslash,), 114 | "S-Left": (Keys.ShiftLeft,), 115 | "S-Right": (Keys.ShiftRight,), 116 | "S-Up": (Keys.ShiftUp,), 117 | "S-Down": (Keys.ShiftDown,), 118 | "M-C-a": (Keys.Escape, Keys.ControlA), 119 | "M-C-b": (Keys.Escape, Keys.ControlB), 120 | "M-C-c": (Keys.Escape, Keys.ControlC), 121 | "M-C-d": (Keys.Escape, Keys.ControlD), 122 | "M-C-e": (Keys.Escape, Keys.ControlE), 123 | "M-C-f": (Keys.Escape, Keys.ControlF), 124 | "M-C-g": (Keys.Escape, Keys.ControlG), 125 | "M-C-h": (Keys.Escape, Keys.ControlH), 126 | "M-C-i": (Keys.Escape, Keys.ControlI), 127 | "M-C-j": (Keys.Escape, Keys.ControlJ), 128 | "M-C-k": (Keys.Escape, Keys.ControlK), 129 | "M-C-l": (Keys.Escape, Keys.ControlL), 130 | "M-C-m": (Keys.Escape, Keys.ControlM), 131 | "M-C-n": (Keys.Escape, Keys.ControlN), 132 | "M-C-o": (Keys.Escape, Keys.ControlO), 133 | "M-C-p": (Keys.Escape, Keys.ControlP), 134 | "M-C-q": (Keys.Escape, Keys.ControlQ), 135 | "M-C-r": (Keys.Escape, Keys.ControlR), 136 | "M-C-s": (Keys.Escape, Keys.ControlS), 137 | "M-C-t": (Keys.Escape, Keys.ControlT), 138 | "M-C-u": (Keys.Escape, Keys.ControlU), 139 | "M-C-v": (Keys.Escape, Keys.ControlV), 140 | "M-C-w": (Keys.Escape, Keys.ControlW), 141 | "M-C-x": (Keys.Escape, Keys.ControlX), 142 | "M-C-y": (Keys.Escape, Keys.ControlY), 143 | "M-C-z": (Keys.Escape, Keys.ControlZ), 144 | "M-C-Left": (Keys.Escape, Keys.ControlLeft), 145 | "M-C-Right": (Keys.Escape, Keys.ControlRight), 146 | "M-C-Up": (Keys.Escape, Keys.ControlUp), 147 | "M-C-Down": (Keys.Escape, Keys.ControlDown), 148 | "M-C-\\": (Keys.Escape, Keys.ControlBackslash), 149 | "M-a": (Keys.Escape, "a"), 150 | "M-b": (Keys.Escape, "b"), 151 | "M-c": (Keys.Escape, "c"), 152 | "M-d": (Keys.Escape, "d"), 153 | "M-e": (Keys.Escape, "e"), 154 | "M-f": (Keys.Escape, "f"), 155 | "M-g": (Keys.Escape, "g"), 156 | "M-h": (Keys.Escape, "h"), 157 | "M-i": (Keys.Escape, "i"), 158 | "M-j": (Keys.Escape, "j"), 159 | "M-k": (Keys.Escape, "k"), 160 | "M-l": (Keys.Escape, "l"), 161 | "M-m": (Keys.Escape, "m"), 162 | "M-n": (Keys.Escape, "n"), 163 | "M-o": (Keys.Escape, "o"), 164 | "M-p": (Keys.Escape, "p"), 165 | "M-q": (Keys.Escape, "q"), 166 | "M-r": (Keys.Escape, "r"), 167 | "M-s": (Keys.Escape, "s"), 168 | "M-t": (Keys.Escape, "t"), 169 | "M-u": (Keys.Escape, "u"), 170 | "M-v": (Keys.Escape, "v"), 171 | "M-w": (Keys.Escape, "w"), 172 | "M-x": (Keys.Escape, "x"), 173 | "M-y": (Keys.Escape, "y"), 174 | "M-z": (Keys.Escape, "z"), 175 | "M-0": (Keys.Escape, "0"), 176 | "M-1": (Keys.Escape, "1"), 177 | "M-2": (Keys.Escape, "2"), 178 | "M-3": (Keys.Escape, "3"), 179 | "M-4": (Keys.Escape, "4"), 180 | "M-5": (Keys.Escape, "5"), 181 | "M-6": (Keys.Escape, "6"), 182 | "M-7": (Keys.Escape, "7"), 183 | "M-8": (Keys.Escape, "8"), 184 | "M-9": (Keys.Escape, "9"), 185 | "M-Up": (Keys.Escape, Keys.Up), 186 | "M-Down": (Keys.Escape, Keys.Down), 187 | "M-Left": (Keys.Escape, Keys.Left), 188 | "M-Right": (Keys.Escape, Keys.Right), 189 | "Left": (Keys.Left,), 190 | "Right": (Keys.Right,), 191 | "Up": (Keys.Up,), 192 | "Down": (Keys.Down,), 193 | "BSpace": (Keys.Backspace,), 194 | "BTab": (Keys.BackTab,), 195 | "DC": (Keys.Delete,), 196 | "IC": (Keys.Insert,), 197 | "End": (Keys.End,), 198 | "Enter": (Keys.ControlJ,), 199 | "Home": (Keys.Home,), 200 | "Escape": (Keys.Escape,), 201 | "Tab": (Keys.Tab,), 202 | "F1": (Keys.F1,), 203 | "F2": (Keys.F2,), 204 | "F3": (Keys.F3,), 205 | "F4": (Keys.F4,), 206 | "F5": (Keys.F5,), 207 | "F6": (Keys.F6,), 208 | "F7": (Keys.F7,), 209 | "F8": (Keys.F8,), 210 | "F9": (Keys.F9,), 211 | "F10": (Keys.F10,), 212 | "F11": (Keys.F11,), 213 | "F12": (Keys.F12,), 214 | "F13": (Keys.F13,), 215 | "F14": (Keys.F14,), 216 | "F15": (Keys.F15,), 217 | "F16": (Keys.F16,), 218 | "F17": (Keys.F17,), 219 | "F18": (Keys.F18,), 220 | "F19": (Keys.F19,), 221 | "F20": (Keys.F20,), 222 | "NPage": (Keys.PageDown,), 223 | "PageDown": (Keys.PageDown,), 224 | "PgDn": (Keys.PageDown,), 225 | "PPage": (Keys.PageUp,), 226 | "PageUp": (Keys.PageUp,), 227 | "PgUp": (Keys.PageUp,), 228 | } 229 | -------------------------------------------------------------------------------- /ptterm/process.py: -------------------------------------------------------------------------------- 1 | """ 2 | The child process. 3 | """ 4 | import time 5 | from asyncio import get_event_loop 6 | from typing import Callable, Optional 7 | 8 | from prompt_toolkit.eventloop import call_soon_threadsafe 9 | 10 | from .backends import Backend 11 | from .key_mappings import prompt_toolkit_key_to_vt100_key 12 | from .screen import BetterScreen 13 | from .stream import BetterStream 14 | 15 | __all__ = ["Process"] 16 | 17 | 18 | class Process: 19 | """ 20 | Child process. 21 | Functionality for parsing the vt100 output (the Pyte screen and stream), as 22 | well as sending input to the process. 23 | 24 | Usage: 25 | 26 | p = Process(loop, ...): 27 | p.start() 28 | 29 | :param invalidate: When the screen content changes, and the renderer needs 30 | to redraw the output, this callback is called. 31 | :param bell_func: Called when the process does a `bell`. 32 | :param done_callback: Called when the process terminates. 33 | :param has_priority: Callable that returns True when this Process should 34 | get priority in the event loop. (When this pane has the focus.) 35 | Otherwise output can be delayed. 36 | """ 37 | 38 | def __init__( 39 | self, 40 | invalidate: Callable[[], None], 41 | backend: Backend, 42 | bell_func: Optional[Callable[[], None]] = None, 43 | done_callback: Optional[Callable[[], None]] = None, 44 | has_priority: Optional[Callable[[], bool]] = None, 45 | ) -> None: 46 | self.loop = get_event_loop() 47 | self.invalidate = invalidate 48 | self.backend = backend 49 | self.done_callback = done_callback 50 | self.has_priority = has_priority or (lambda: True) 51 | 52 | self.suspended = False 53 | self._reader_connected = False 54 | 55 | # Create terminal interface. 56 | self.backend.add_input_ready_callback(self._read) 57 | 58 | if done_callback is not None: 59 | self.backend.ready_f.add_done_callback(lambda _: done_callback()) 60 | 61 | # Create output stream and attach to screen 62 | self.sx = 0 63 | self.sy = 0 64 | 65 | self.screen = BetterScreen( 66 | self.sx, self.sy, write_process_input=self.write_input, bell_func=bell_func 67 | ) 68 | 69 | self.stream = BetterStream(self.screen) 70 | self.stream.attach(self.screen) 71 | 72 | def start(self) -> None: 73 | """ 74 | Start the process: fork child. 75 | """ 76 | self.set_size(120, 24) 77 | self.backend.start() 78 | self.backend.connect_reader() 79 | 80 | def set_size(self, width: int, height: int) -> None: 81 | """ 82 | Set terminal size. 83 | """ 84 | if (self.sx, self.sy) != (width, height): 85 | self.backend.set_size(width, height) 86 | self.screen.resize(lines=height, columns=width) 87 | 88 | self.screen.lines = height 89 | self.screen.columns = width 90 | 91 | self.sx = width 92 | self.sy = height 93 | 94 | def write_input(self, data: str, paste: bool = False) -> None: 95 | """ 96 | Write user key strokes to the input. 97 | 98 | :param data: (text, not bytes.) The input. 99 | :param paste: When True, and the process running here understands 100 | bracketed paste. Send as pasted text. 101 | """ 102 | # send as bracketed paste? 103 | if paste and self.screen.bracketed_paste_enabled: 104 | data = "\x1b[200~" + data + "\x1b[201~" 105 | 106 | self.backend.write_text(data) 107 | 108 | def write_key(self, key: str) -> None: 109 | """ 110 | Write prompt_toolkit Key. 111 | """ 112 | data = prompt_toolkit_key_to_vt100_key( 113 | key, application_mode=self.screen.in_application_mode 114 | ) 115 | self.write_input(data) 116 | 117 | def _read(self) -> None: 118 | """ 119 | Read callback, called by the loop. 120 | """ 121 | d = self.backend.read_text(4096) 122 | assert isinstance(d, str), "got %r" % type(d) 123 | # Make sure not to read too much at once. (Otherwise, this 124 | # could block the event loop.) 125 | 126 | if not self.backend.closed: 127 | 128 | def process() -> None: 129 | self.stream.feed(d) 130 | self.invalidate() 131 | 132 | # Feed directly, if this process has priority. (That is when this 133 | # pane has the focus in any of the clients.) 134 | if self.has_priority(): 135 | process() 136 | 137 | # Otherwise, postpone processing until we have CPU time available. 138 | else: 139 | self.backend.disconnect_reader() 140 | 141 | def do_asap(): 142 | "Process output and reconnect to event loop." 143 | process() 144 | if not self.suspended: 145 | self.backend.connect_reader() 146 | 147 | # When the event loop is saturated because of CPU, we will 148 | # postpone this processing max 'x' seconds. 149 | 150 | # '1' seems like a reasonable value, because that way we say 151 | # that we will process max 1k/1s in case of saturation. 152 | # That should be enough to prevent the UI from feeling 153 | # unresponsive. 154 | timestamp = time.time() + 1 155 | 156 | call_soon_threadsafe(do_asap, max_postpone_time=timestamp) 157 | else: 158 | # End of stream. Remove child. 159 | self.backend.disconnect_reader() 160 | 161 | def suspend(self) -> None: 162 | """ 163 | Suspend process. Stop reading stdout. (Called when going into copy mode.) 164 | """ 165 | if not self.suspended: 166 | self.suspended = True 167 | self.backend.disconnect_reader() 168 | 169 | def resume(self) -> None: 170 | """ 171 | Resume from 'suspend'. 172 | """ 173 | if self.suspended: 174 | self.backend.connect_reader() 175 | self.suspended = False 176 | 177 | def get_cwd(self) -> str: 178 | """ 179 | The current working directory for this process. (Or `None` when 180 | unknown.) 181 | """ 182 | return self.backend.get_cwd() 183 | 184 | def get_name(self) -> str: 185 | """ 186 | The name for this process. (Or `None` when unknown.) 187 | """ 188 | # TODO: Maybe cache for short time. 189 | return self.backend.get_name() 190 | 191 | def kill(self) -> None: 192 | """ 193 | Kill process. 194 | """ 195 | self.backend.kill() 196 | 197 | @property 198 | def is_terminated(self) -> bool: 199 | return self.backend.closed 200 | -------------------------------------------------------------------------------- /ptterm/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prompt-toolkit/ptterm/ead3a206d950a8f9a201a99da886d5325dfd86aa/ptterm/py.typed -------------------------------------------------------------------------------- /ptterm/screen.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom `Screen` class for the `pyte` library. 3 | 4 | Changes compared to the original `Screen` class: 5 | - We store the layout in a prompt_toolkit.layout.screen.Screen instance. 6 | This allows fast rendering in a prompt_toolkit user control. 7 | - 256 colour and true color support. 8 | - CPR support and device attributes. 9 | """ 10 | from collections import defaultdict, namedtuple 11 | from typing import Callable, Dict, List, Optional, Tuple 12 | 13 | from prompt_toolkit.cache import FastDictCache 14 | from prompt_toolkit.layout.screen import Char, Screen 15 | from prompt_toolkit.output.vt100 import BG_ANSI_COLORS, FG_ANSI_COLORS 16 | from prompt_toolkit.output.vt100 import _256_colors as _256_colors_table 17 | from prompt_toolkit.styles import Attrs 18 | from pyte import charsets as cs 19 | from pyte import modes as mo 20 | from pyte.screens import Margins 21 | 22 | __all__ = ("BetterScreen",) 23 | 24 | 25 | class CursorPosition: 26 | "Mutable CursorPosition." 27 | 28 | def __init__(self, x: int = 0, y: int = 0) -> None: 29 | self.x = x 30 | self.y = y 31 | 32 | def __repr__(self) -> str: 33 | return f"pymux.CursorPosition(x={self.x!r}, y={self.y!r})" 34 | 35 | 36 | class _UnicodeInternDict(Dict[str, str]): 37 | """ 38 | Intern dictionary for interning unicode strings. This should save memory 39 | and make our cache faster. 40 | """ 41 | 42 | def __missing__(self, value: str) -> str: 43 | self[value] = value 44 | return value 45 | 46 | 47 | _unicode_intern_dict = _UnicodeInternDict() 48 | 49 | 50 | # Cache for Char objects. 51 | _CHAR_CACHE: FastDictCache[Tuple[str, str], Char] = FastDictCache( 52 | Char, size=1000 * 1000 53 | ) 54 | 55 | 56 | # Custom Savepoint that also stores the Attrs. 57 | _Savepoint = namedtuple( 58 | "_Savepoint", 59 | [ 60 | "cursor_x", 61 | "cursor_y", 62 | "g0_charset", 63 | "g1_charset", 64 | "charset", 65 | "origin", 66 | "wrap", 67 | "attrs", 68 | "style_str", 69 | ], 70 | ) 71 | 72 | 73 | class BetterScreen: 74 | """ 75 | Custom screen class. Most of the methods are called from a vt100 Pyte 76 | stream. 77 | 78 | The data buffer is stored in a :class:`prompt_toolkit.layout.screen.Screen` 79 | class, because this way, we can send it to the renderer without any 80 | transformation. 81 | """ 82 | 83 | swap_variables = [ 84 | "mode", 85 | "margins", 86 | "charset", 87 | "g0_charset", 88 | "g1_charset", 89 | "tabstops", 90 | "data_buffer", 91 | "pt_cursor_position", 92 | "max_y", 93 | ] 94 | 95 | def __init__( 96 | self, 97 | lines: int, 98 | columns: int, 99 | write_process_input: Callable[[str], None], 100 | bell_func: Optional[Callable[[], None]] = None, 101 | get_history_limit: Optional[Callable[[], int]] = None, 102 | ) -> None: 103 | bell_func = bell_func or (lambda: None) 104 | get_history_limit = get_history_limit or (lambda: 2000) 105 | 106 | self._history_cleanup_counter = 0 107 | 108 | self.savepoints: List[_Savepoint] = [] 109 | self.lines = lines 110 | self.columns = columns 111 | self.write_process_input = write_process_input 112 | self.bell_func = bell_func 113 | self.get_history_limit = get_history_limit 114 | self.reset() 115 | 116 | @property 117 | def in_application_mode(self) -> bool: 118 | """ 119 | True when we are in application mode. This means that the process is 120 | expecting some other key sequences as input. (Like for the arrows.) 121 | """ 122 | # Not in cursor mode. 123 | return (1 << 5) in self.mode 124 | 125 | @property 126 | def mouse_support_enabled(self) -> bool: 127 | "True when mouse support has been enabled by the application." 128 | return (1000 << 5) in self.mode 129 | 130 | @property 131 | def urxvt_mouse_support_enabled(self) -> bool: 132 | return (1015 << 5) in self.mode 133 | 134 | @property 135 | def sgr_mouse_support_enabled(self) -> bool: 136 | "Xterm Sgr mouse support." 137 | return (1006 << 5) in self.mode 138 | 139 | @property 140 | def bracketed_paste_enabled(self) -> bool: 141 | return (2004 << 5) in self.mode 142 | 143 | @property 144 | def has_reverse_video(self) -> bool: 145 | "The whole screen is set to reverse video." 146 | return mo.DECSCNM in self.mode 147 | 148 | def reset(self) -> None: 149 | """Resets the terminal to its initial state. 150 | 151 | * Scroll margins are reset to screen boundaries. 152 | * Cursor is moved to home location -- ``(0, 0)`` and its 153 | attributes are set to defaults (see :attr:`default_char`). 154 | * Screen is cleared -- each character is reset to 155 | :attr:`default_char`. 156 | * Tabstops are reset to "every eight columns". 157 | 158 | .. note:: 159 | 160 | Neither VT220 nor VT102 manuals mentioned that terminal modes 161 | and tabstops should be reset as well, thanks to 162 | :manpage:`xterm` -- we now know that. 163 | """ 164 | self._reset_screen() 165 | 166 | self.title = "" 167 | self.icon_name = "" 168 | 169 | # Reset modes. 170 | self.mode = { 171 | mo.DECAWM, # Autowrap mode. (default: disabled). 172 | mo.DECTCEM, # Text cursor enable mode. (default enabled). 173 | } 174 | 175 | # According to VT220 manual and ``linux/drivers/tty/vt.c`` 176 | # the default G0 charset is latin-1, but for reasons unknown 177 | # latin-1 breaks ascii-graphics; so G0 defaults to cp437. 178 | 179 | # XXX: The comment above comes from the original Pyte implementation, 180 | # it seems for us that LAT1_MAP should indeed be the default, if 181 | # not a French version of Vim would incorrectly show some 182 | # characters. 183 | self.charset = 0 184 | # self.g0_charset = cs.IBMPC_MAP 185 | self.g0_charset = cs.LAT1_MAP 186 | self.g1_charset = cs.VT100_MAP 187 | 188 | # From ``man terminfo`` -- "... hardware tabs are initially 189 | # set every `n` spaces when the terminal is powered up. Since 190 | # we aim to support VT102 / VT220 and linux -- we use n = 8. 191 | 192 | # (We choose to create tab stops until x=1000, because we keep the 193 | # tab stops when the screen increases in size. The OS X 'ls' command 194 | # relies on the stops to be there.) 195 | self.tabstops = set(range(8, 1000, 8)) 196 | 197 | # The original Screen instance, when going to the alternate screen. 198 | self._original_screen: Optional[Screen] = None 199 | 200 | def _reset_screen(self) -> None: 201 | """Reset the Screen content. (also called when switching from/to 202 | alternate buffer.""" 203 | self.pt_screen = Screen( 204 | default_char=Char(" ", "") 205 | ) # TODO: Stop using this Screen class! 206 | 207 | self.pt_screen.show_cursor = True 208 | 209 | self.data_buffer = self.pt_screen.data_buffer 210 | self.pt_cursor_position = CursorPosition(0, 0) 211 | self.wrapped_lines: List[int] = [] # List of line indexes that were wrapped. 212 | 213 | self._attrs = Attrs( 214 | color=None, 215 | bgcolor=None, 216 | bold=False, 217 | underline=False, 218 | strike=False, 219 | italic=False, 220 | blink=False, 221 | reverse=False, 222 | hidden=False, 223 | ) 224 | self._style_str = "" 225 | 226 | self.margins = None 227 | 228 | self.max_y = 0 # Max 'y' position to which is written. 229 | 230 | def resize( 231 | self, lines: Optional[int] = None, columns: Optional[int] = None 232 | ) -> None: 233 | # Save the dimensions. 234 | lines = lines if lines is not None else self.lines 235 | columns = columns if columns is not None else self.columns 236 | 237 | if self.lines != lines or self.columns != columns: 238 | self.lines = lines 239 | self.columns = columns 240 | 241 | self._reset_offset_and_margins() 242 | 243 | # If the height was reduced, and there are lines below 244 | # `cursor_position_y+lines`. Remove them by setting 'max_y'. 245 | # (If we don't do this. Clearing the screen, followed by reducing 246 | # the height will keep the cursor at the top, hiding some content.) 247 | self.max_y = min(self.max_y, self.pt_cursor_position.y + lines - 1) 248 | 249 | self._reflow() 250 | 251 | @property 252 | def line_offset(self) -> int: 253 | "Return the index of the first visible line." 254 | cpos_y = self.pt_cursor_position.y 255 | 256 | # NOTE: the +1 is required because `max_y` starts counting at 0 for the 257 | # first line, while `self.lines` is the number of lines, starting 258 | # at 1 for one line. The offset refers to the index of the first 259 | # visible line. 260 | # For instance, if we have: max_y=14 and lines=15. Then all lines 261 | # from 0..14 have been used. This means 15 lines are used, and 262 | # the first index should be 0. 263 | return max(0, min(cpos_y, self.max_y - self.lines + 1)) 264 | 265 | def set_margins( 266 | self, top: Optional[int] = None, bottom: Optional[int] = None 267 | ) -> None: 268 | """Selects top and bottom margins for the scrolling region. 269 | Margins determine which screen lines move during scrolling 270 | (see :meth:`index` and :meth:`reverse_index`). Characters added 271 | outside the scrolling region do not cause the screen to scroll. 272 | :param int top: the smallest line number that is scrolled. 273 | :param int bottom: the biggest line number that is scrolled. 274 | """ 275 | if top is None and bottom is None: 276 | return 277 | 278 | margins = self.margins or Margins(0, self.lines - 1) 279 | 280 | top = margins.top if top is None else top - 1 281 | bottom = margins.bottom if bottom is None else bottom - 1 282 | 283 | # Arguments are 1-based, while :attr:`margins` are zero based -- 284 | # so we have to decrement them by one. We also make sure that 285 | # both of them is bounded by [0, lines - 1]. 286 | top = max(0, min(top, self.lines - 1)) 287 | bottom = max(0, min(bottom, self.lines - 1)) 288 | 289 | # Even though VT102 and VT220 require DECSTBM to ignore regions 290 | # of width less than 2, some programs (like aptitude for example) 291 | # rely on it. Practicality beats purity. 292 | if bottom - top >= 1: 293 | self.margins = Margins(top, bottom) 294 | 295 | # The cursor moves to the home position when the top and 296 | # bottom margins of the scrolling region (DECSTBM) changes. 297 | self.cursor_position() 298 | 299 | def _reset_offset_and_margins(self) -> None: 300 | """ 301 | Recalculate offset and move cursor (make sure that the bottom is 302 | visible.) 303 | """ 304 | self.margins = None 305 | 306 | def set_charset(self, code, mode) -> None: 307 | """Set active ``G0`` or ``G1`` charset. 308 | 309 | :param str code: character set code, should be a character 310 | from ``"B0UK"`` -- otherwise ignored. 311 | :param str mode: if ``"("`` ``G0`` charset is set, if 312 | ``")"`` -- we operate on ``G1``. 313 | 314 | .. warning:: User-defined charsets are currently not supported. 315 | """ 316 | if code in cs.MAPS: 317 | charset_map = cs.MAPS[code] 318 | if mode == "(": 319 | self.g0_charset = charset_map 320 | elif mode == ")": 321 | self.g1_charset = charset_map 322 | 323 | def set_mode(self, *modes, **kwargs) -> None: 324 | # Private mode codes are shifted, to be distingiushed from non 325 | # private ones. 326 | if kwargs.get("private"): 327 | modes = tuple(mode << 5 for mode in modes) 328 | 329 | self.mode.update(modes) 330 | 331 | # When DECOLM mode is set, the screen is erased and the cursor 332 | # moves to the home position. 333 | if mo.DECCOLM in modes: 334 | self.resize(columns=132) 335 | self.erase_in_display(2) 336 | self.cursor_position() 337 | 338 | # According to `vttest`, DECOM should also home the cursor, see 339 | # vttest/main.c:303. 340 | if mo.DECOM in modes: 341 | self.cursor_position() 342 | 343 | # Make the cursor visible. 344 | if mo.DECTCEM in modes: 345 | self.pt_screen.show_cursor = True 346 | 347 | # On "\e[?1049h", enter alternate screen mode. Backup the current state, 348 | if (1049 << 5) in modes: 349 | self._original_screen = self.pt_screen 350 | self._original_screen_vars = { 351 | v: getattr(self, v) for v in self.swap_variables 352 | } 353 | self._reset_screen() 354 | self._reset_offset_and_margins() 355 | 356 | def reset_mode(self, *modes_args, **kwargs) -> None: 357 | """Resets (disables) a given list of modes. 358 | 359 | :param list modes: modes to reset -- hopefully, each mode is a 360 | constant from :mod:`pyte.modes`. 361 | """ 362 | modes = list(modes_args) 363 | 364 | # Private mode codes are shifted, to be distingiushed from non 365 | # private ones. 366 | if kwargs.get("private"): 367 | modes = [mode << 5 for mode in modes] 368 | 369 | self.mode.difference_update(modes) 370 | 371 | # Lines below follow the logic in :meth:`set_mode`. 372 | if mo.DECCOLM in modes: 373 | self.resize(columns=80) 374 | self.erase_in_display(2) 375 | self.cursor_position() 376 | 377 | if mo.DECOM in modes: 378 | self.cursor_position() 379 | 380 | # Hide the cursor. 381 | if mo.DECTCEM in modes: 382 | self.pt_screen.show_cursor = False 383 | 384 | # On "\e[?1049l", restore from alternate screen mode. 385 | if (1049 << 5) in modes and self._original_screen: 386 | for k, v in self._original_screen_vars.items(): 387 | setattr(self, k, v) 388 | self.pt_screen = self._original_screen 389 | 390 | self._original_screen = None 391 | self._original_screen_vars = {} 392 | self._reset_offset_and_margins() 393 | 394 | @property 395 | def in_alternate_screen(self) -> bool: 396 | return bool(self._original_screen) 397 | 398 | def shift_in(self) -> None: 399 | "Activates ``G0`` character set." 400 | self.charset = 0 401 | 402 | def shift_out(self) -> None: 403 | "Activates ``G1`` character set." 404 | self.charset = 1 405 | 406 | def draw(self, chars: str) -> None: 407 | """ 408 | Draw characters. 409 | `chars` is supposed to *not* contain any special characters. 410 | No newlines or control codes. 411 | """ 412 | # Aliases for variables that are used more than once in this function. 413 | # Local lookups are always faster. 414 | # (This draw function is called for every printable character that a 415 | # process outputs; it should be as performant as possible.) 416 | pt_screen = self.pt_screen 417 | data_buffer = pt_screen.data_buffer 418 | cursor_position = self.pt_cursor_position 419 | cursor_position_x = cursor_position.x 420 | cursor_position_y = cursor_position.y 421 | 422 | in_irm = mo.IRM in self.mode 423 | char_cache = _CHAR_CACHE 424 | columns = self.columns 425 | 426 | # Translating a given character. 427 | if self.charset: 428 | chars = chars.translate(self.g1_charset) 429 | else: 430 | chars = chars.translate(self.g0_charset) 431 | 432 | style = self._style_str 433 | 434 | for char in chars: 435 | # Create 'Char' instance. 436 | pt_char = char_cache[char, style] 437 | char_width = pt_char.width 438 | 439 | # If this was the last column in a line and auto wrap mode is 440 | # enabled, move the cursor to the beginning of the next line, 441 | # otherwise replace characters already displayed with newly 442 | # entered. 443 | if cursor_position_x >= columns: 444 | if mo.DECAWM in self.mode: 445 | self.carriage_return() 446 | self.linefeed() 447 | cursor_position = self.pt_cursor_position 448 | cursor_position_x = cursor_position.x 449 | cursor_position_y = cursor_position.y 450 | 451 | self.wrapped_lines.append(cursor_position_y) 452 | else: 453 | cursor_position_x -= max(0, char_width) 454 | 455 | # If Insert mode is set, new characters move old characters to 456 | # the right, otherwise terminal is in Replace mode and new 457 | # characters replace old characters at cursor position. 458 | if in_irm: 459 | self.insert_characters(max(0, char_width)) 460 | 461 | row = data_buffer[cursor_position_y] 462 | if char_width == 1: 463 | row[cursor_position_x] = pt_char 464 | elif char_width > 1: # 2 465 | # Double width character. Put an empty string in the second 466 | # cell, because this is different from every character and 467 | # causes the render engine to clear this character, when 468 | # overwritten. 469 | row[cursor_position_x] = pt_char 470 | row[cursor_position_x + 1] = char_cache["", style] 471 | elif char_width == 0: 472 | # This is probably a part of a decomposed unicode character. 473 | # Merge into the previous cell. 474 | # See: https://en.wikipedia.org/wiki/Unicode_equivalence 475 | prev_char = row[cursor_position_x - 1] 476 | row[cursor_position_x - 1] = char_cache[ 477 | prev_char.char + pt_char.char, prev_char.style 478 | ] 479 | else: # char_width < 0 480 | # (Should not happen.) 481 | char_width = 0 482 | 483 | # .. note:: We can't use :meth:`cursor_forward()`, because that 484 | # way, we'll never know when to linefeed. 485 | cursor_position_x += char_width 486 | 487 | # Update max_y. (Don't use 'max()' for comparing only two values, that 488 | # is less efficient.) 489 | if cursor_position_y > self.max_y: 490 | self.max_y = cursor_position_y 491 | 492 | cursor_position.x = cursor_position_x 493 | 494 | def carriage_return(self) -> None: 495 | "Move the cursor to the beginning of the current line." 496 | self.pt_cursor_position.x = 0 497 | 498 | def index(self) -> None: 499 | """Move the cursor down one line in the same column. If the 500 | cursor is at the last line, create a new line at the bottom. 501 | """ 502 | margins = self.margins 503 | 504 | # When scrolling over the full screen height -> keep history. 505 | if margins is None: 506 | # Simply move the cursor one position down. 507 | cursor_position = self.pt_cursor_position 508 | cursor_position.y += 1 509 | self.max_y = max(self.max_y, cursor_position.y) 510 | 511 | # Cleanup the history, but only every 100 calls. 512 | self._history_cleanup_counter += 1 513 | if self._history_cleanup_counter == 100: 514 | self._remove_old_lines_from_history() 515 | self._history_cleanup_counter = 0 516 | else: 517 | # Move cursor down, but scroll in the scrolling region. 518 | top, bottom = self.margins 519 | line_offset = self.line_offset 520 | 521 | if self.pt_cursor_position.y - line_offset == bottom: 522 | data_buffer = self.data_buffer 523 | 524 | for line in range(top, bottom): 525 | data_buffer[line + line_offset] = data_buffer[ 526 | line + line_offset + 1 527 | ] 528 | data_buffer.pop(line + line_offset + 1, None) 529 | else: 530 | self.cursor_down() 531 | 532 | def _remove_old_lines_from_history(self) -> None: 533 | """ 534 | Remove top from the scroll buffer. (Outside bounds of history limit.) 535 | """ 536 | remove_above = max(0, self.pt_cursor_position.y - self.get_history_limit()) 537 | data_buffer = self.pt_screen.data_buffer 538 | for line in list(data_buffer): 539 | if line < remove_above: 540 | data_buffer.pop(line, None) 541 | 542 | def clear_history(self) -> None: 543 | """ 544 | Delete all history from the scroll buffer. 545 | """ 546 | for line in list(self.data_buffer): 547 | if line < self.line_offset: 548 | self.data_buffer.pop(line, None) 549 | 550 | def reverse_index(self) -> None: 551 | margins = self.margins or Margins(0, self.lines - 1) 552 | top, bottom = margins 553 | line_offset = self.line_offset 554 | 555 | # When scrolling over the full screen -> keep history. 556 | if self.pt_cursor_position.y - line_offset == top: 557 | for i in range(bottom - 1, top - 1, -1): 558 | self.data_buffer[i + line_offset + 1] = self.data_buffer[ 559 | i + line_offset 560 | ] 561 | self.data_buffer.pop(i + line_offset, None) 562 | else: 563 | self.cursor_up() 564 | 565 | def linefeed(self) -> None: 566 | """Performs an index and, if :data:`~pyte.modes.LNM` is set, a 567 | carriage return. 568 | """ 569 | self.index() 570 | 571 | if mo.LNM in self.mode: 572 | self.carriage_return() 573 | 574 | def next_line(self) -> None: 575 | """When `EscE` has been received. Go to the next line, even when LNM has 576 | not been set.""" 577 | self.index() 578 | self.carriage_return() 579 | self.ensure_bounds() 580 | 581 | def tab(self) -> None: 582 | """Move to the next tab space, or the end of the screen if there 583 | aren't anymore left. 584 | """ 585 | for stop in sorted(self.tabstops): 586 | if self.pt_cursor_position.x < stop: 587 | column = stop 588 | break 589 | else: 590 | column = self.columns - 1 591 | 592 | self.pt_cursor_position.x = column 593 | 594 | def backspace(self) -> None: 595 | """Move cursor to the left one or keep it in it's position if 596 | it's at the beginning of the line already. 597 | """ 598 | self.cursor_back() 599 | 600 | def save_cursor(self) -> None: 601 | """Push the current cursor position onto the stack.""" 602 | self.savepoints.append( 603 | _Savepoint( 604 | self.pt_cursor_position.x, 605 | self.pt_cursor_position.y, 606 | self.g0_charset, 607 | self.g1_charset, 608 | self.charset, 609 | mo.DECOM in self.mode, 610 | mo.DECAWM in self.mode, 611 | self._attrs, 612 | self._style_str, 613 | ) 614 | ) 615 | 616 | def restore_cursor(self) -> None: 617 | """Set the current cursor position to whatever cursor is on top 618 | of the stack. 619 | """ 620 | if self.savepoints: 621 | savepoint = self.savepoints.pop() 622 | 623 | self.g0_charset = savepoint.g0_charset 624 | self.g1_charset = savepoint.g1_charset 625 | self.charset = savepoint.charset 626 | self._attrs = savepoint.attrs 627 | self._style_str = savepoint.style_str 628 | 629 | if savepoint.origin: 630 | self.set_mode(mo.DECOM) 631 | if savepoint.wrap: 632 | self.set_mode(mo.DECAWM) 633 | 634 | self.pt_cursor_position.x = savepoint.cursor_x 635 | self.pt_cursor_position.y = savepoint.cursor_y 636 | self.ensure_bounds(use_margins=True) 637 | else: 638 | # If nothing was saved, the cursor moves to home position; 639 | # origin mode is reset. :todo: DECAWM? 640 | self.reset_mode(mo.DECOM) 641 | self.cursor_position() 642 | 643 | def insert_lines(self, count: Optional[int] = None) -> None: 644 | """Inserts the indicated # of lines at line with cursor. Lines 645 | displayed **at** and below the cursor move down. Lines moved 646 | past the bottom margin are lost. 647 | 648 | :param count: number of lines to delete. 649 | """ 650 | count = count or 1 651 | top, bottom = self.margins or Margins(0, self.lines - 1) 652 | 653 | data_buffer = self.data_buffer 654 | line_offset = self.line_offset 655 | pt_cursor_position = self.pt_cursor_position 656 | 657 | # If cursor is outside scrolling margins it -- do nothing. 658 | if top <= pt_cursor_position.y - self.line_offset <= bottom: 659 | for line in range(bottom, pt_cursor_position.y - line_offset, -1): 660 | if line - count < top: 661 | data_buffer.pop(line + line_offset, None) 662 | else: 663 | data_buffer[line + line_offset] = data_buffer[ 664 | line + line_offset - count 665 | ] 666 | data_buffer.pop(line + line_offset - count, None) 667 | 668 | self.carriage_return() 669 | 670 | def delete_lines(self, count: Optional[int] = None) -> None: 671 | """Deletes the indicated # of lines, starting at line with 672 | cursor. As lines are deleted, lines displayed below cursor 673 | move up. Lines added to bottom of screen have spaces with same 674 | character attributes as last line moved up. 675 | 676 | :param int count: number of lines to delete. 677 | """ 678 | count = count or 1 679 | top, bottom = self.margins or Margins(0, self.lines - 1) 680 | line_offset = self.line_offset 681 | pt_cursor_position = self.pt_cursor_position 682 | 683 | # If cursor is outside scrolling margins it -- do nothin'. 684 | if top <= pt_cursor_position.y - line_offset <= bottom: 685 | data_buffer = self.data_buffer 686 | 687 | # Iterate from the cursor Y position until the end of the visible input. 688 | for line in range(pt_cursor_position.y - line_offset, bottom + 1): 689 | # When 'x' lines further are out of the margins, replace by an empty line, 690 | # Otherwise copy the line from there. 691 | if line + count > bottom: 692 | data_buffer.pop(line + line_offset, None) 693 | else: 694 | data_buffer[line + line_offset] = self.data_buffer[ 695 | line + count + line_offset 696 | ] 697 | 698 | def insert_characters(self, count: Optional[int] = None) -> None: 699 | """Inserts the indicated # of blank characters at the cursor 700 | position. The cursor does not move and remains at the beginning 701 | of the inserted blank characters. Data on the line is shifted 702 | forward. 703 | 704 | :param int count: number of characters to insert. 705 | """ 706 | count = count or 1 707 | 708 | line = self.data_buffer[self.pt_cursor_position.y] 709 | 710 | if line: 711 | max_columns = max(line.keys()) 712 | 713 | for i in range(max_columns, self.pt_cursor_position.x - 1, -1): 714 | line[i + count] = line[i] 715 | del line[i] 716 | 717 | def delete_characters(self, count: Optional[int] = None) -> None: 718 | count = count or 1 719 | 720 | line = self.data_buffer[self.pt_cursor_position.y] 721 | if line: 722 | max_columns = max(line.keys()) 723 | 724 | for i in range(self.pt_cursor_position.x, max_columns + 1): 725 | line[i] = line[i + count] 726 | del line[i + count] 727 | 728 | def cursor_position( 729 | self, line: Optional[int] = None, column: Optional[int] = None 730 | ) -> None: 731 | """Set the cursor to a specific `line` and `column`. 732 | 733 | Cursor is allowed to move out of the scrolling region only when 734 | :data:`~pyte.modes.DECOM` is reset, otherwise -- the position 735 | doesn't change. 736 | 737 | :param int line: line number to move the cursor to. 738 | :param int column: column number to move the cursor to. 739 | """ 740 | column = (column or 1) - 1 741 | line = (line or 1) - 1 742 | 743 | # If origin mode (DECOM) is set, line number are relative to 744 | # the top scrolling margin. 745 | margins = self.margins 746 | 747 | if margins is not None and mo.DECOM in self.mode: 748 | line += margins.top 749 | 750 | # Cursor is not allowed to move out of the scrolling region. 751 | if not (margins.top <= line <= margins.bottom): 752 | return 753 | 754 | self.pt_cursor_position.x = column 755 | self.pt_cursor_position.y = line + self.line_offset 756 | self.ensure_bounds() 757 | 758 | def cursor_to_column(self, column: Optional[int] = None) -> None: 759 | """Moves cursor to a specific column in the current line. 760 | 761 | :param int column: column number to move the cursor to. 762 | """ 763 | self.pt_cursor_position.x = (column or 1) - 1 764 | self.ensure_bounds() 765 | 766 | def cursor_to_line(self, line: Optional[int] = None) -> None: 767 | """Moves cursor to a specific line in the current column. 768 | 769 | :param int line: line number to move the cursor to. 770 | """ 771 | self.pt_cursor_position.y = (line or 1) - 1 + self.line_offset 772 | 773 | # If origin mode (DECOM) is set, line number are relative to 774 | # the top scrolling margin. 775 | margins = self.margins 776 | 777 | if mo.DECOM in self.mode and margins is not None: 778 | self.pt_cursor_position.y += margins.top 779 | 780 | # FIXME: should we also restrict the cursor to the scrolling 781 | # region? 782 | 783 | self.ensure_bounds() 784 | 785 | def bell(self, *args) -> None: 786 | "Bell" 787 | self.bell_func() 788 | 789 | def cursor_down(self, count: Optional[int] = None) -> None: 790 | """Moves cursor down the indicated # of lines in same column. 791 | Cursor stops at bottom margin. 792 | 793 | :param int count: number of lines to skip. 794 | """ 795 | cursor_position = self.pt_cursor_position 796 | margins = self.margins or Margins(0, self.lines - 1) 797 | 798 | # Ensure bounds. 799 | # (Following code is faster than calling `self.ensure_bounds`.) 800 | _, bottom = margins 801 | cursor_position.y = min( 802 | cursor_position.y + (count or 1), bottom + self.line_offset + 1 803 | ) 804 | 805 | self.max_y = max(self.max_y, cursor_position.y) 806 | 807 | def cursor_down1(self, count: Optional[int] = None) -> None: 808 | """Moves cursor down the indicated # of lines to column 1. 809 | Cursor stops at bottom margin. 810 | 811 | :param int count: number of lines to skip. 812 | """ 813 | self.cursor_down(count) 814 | self.carriage_return() 815 | 816 | def cursor_up(self, count: Optional[int] = None) -> None: 817 | """Moves cursor up the indicated # of lines in same column. 818 | Cursor stops at top margin. 819 | 820 | :param int count: number of lines to skip. 821 | """ 822 | self.pt_cursor_position.y -= count or 1 823 | self.ensure_bounds(use_margins=True) 824 | 825 | def cursor_up1(self, count: Optional[int] = None) -> None: 826 | """Moves cursor up the indicated # of lines to column 1. Cursor 827 | stops at bottom margin. 828 | 829 | :param int count: number of lines to skip. 830 | """ 831 | self.cursor_up(count) 832 | self.carriage_return() 833 | 834 | def cursor_back(self, count: Optional[int] = None) -> None: 835 | """Moves cursor left the indicated # of columns. Cursor stops 836 | at left margin. 837 | 838 | :param int count: number of columns to skip. 839 | """ 840 | self.pt_cursor_position.x = max(0, self.pt_cursor_position.x - (count or 1)) 841 | self.ensure_bounds() 842 | 843 | def cursor_forward(self, count: Optional[int] = None) -> None: 844 | """Moves cursor right the indicated # of columns. Cursor stops 845 | at right margin. 846 | 847 | :param int count: number of columns to skip. 848 | """ 849 | self.pt_cursor_position.x += count or 1 850 | self.ensure_bounds() 851 | 852 | def erase_characters(self, count: Optional[int] = None) -> None: 853 | """Erases the indicated # of characters, starting with the 854 | character at cursor position. Character attributes are set 855 | cursor attributes. The cursor remains in the same position. 856 | 857 | :param int count: number of characters to erase. 858 | 859 | .. warning:: 860 | 861 | Even though *ALL* of the VTXXX manuals state that character 862 | attributes **should be reset to defaults**, ``libvte``, 863 | ``xterm`` and ``ROTE`` completely ignore this. Same applies 864 | too all ``erase_*()`` and ``delete_*()`` methods. 865 | """ 866 | count = count or 1 867 | cursor_position = self.pt_cursor_position 868 | row = self.data_buffer[cursor_position.y] 869 | 870 | for column in range( 871 | cursor_position.x, min(cursor_position.x + count, self.columns) 872 | ): 873 | row[column] = Char(style=row[column].style) 874 | 875 | def erase_in_line(self, type_of: int = 0, private: bool = False) -> None: 876 | """Erases a line in a specific way. 877 | 878 | :param int type_of: defines the way the line should be erased in: 879 | 880 | * ``0`` -- Erases from cursor to end of line, including cursor 881 | position. 882 | * ``1`` -- Erases from beginning of line to cursor, 883 | including cursor position. 884 | * ``2`` -- Erases complete line. 885 | :param bool private: when ``True`` character attributes aren left 886 | unchanged **not implemented**. 887 | """ 888 | data_buffer = self.data_buffer 889 | pt_cursor_position = self.pt_cursor_position 890 | 891 | if type_of == 2: 892 | # Delete line completely. 893 | data_buffer.pop(pt_cursor_position.y, None) 894 | else: 895 | line = data_buffer[pt_cursor_position.y] 896 | 897 | def should_we_delete(column): # TODO: check for off-by-one errors! 898 | if type_of == 0: 899 | return column >= pt_cursor_position.x 900 | if type_of == 1: 901 | return column <= pt_cursor_position.x 902 | 903 | for column in list(line.keys()): 904 | if should_we_delete(column): 905 | line.pop(column, None) 906 | 907 | def erase_in_display(self, type_of: int = 0, private: bool = False) -> None: 908 | """Erases display in a specific way. 909 | 910 | :param int type_of: defines the way the line should be erased in: 911 | 912 | * ``0`` -- Erases from cursor to end of screen, including 913 | cursor position. 914 | * ``1`` -- Erases from beginning of screen to cursor, 915 | including cursor position. 916 | * ``2`` -- Erases complete display. All lines are erased 917 | and changed to single-width. Cursor does not move. 918 | * ``3`` -- Erase saved lines. (Xterm) Clears the history. 919 | :param bool private: when ``True`` character attributes aren left 920 | unchanged **not implemented**. 921 | """ 922 | line_offset = self.line_offset 923 | pt_cursor_position = self.pt_cursor_position 924 | try: 925 | max_line = max(self.pt_screen.data_buffer) 926 | except ValueError: 927 | # max() called on empty sequence. Screen is empty. Nothing to erase. 928 | return 929 | 930 | if type_of == 3: 931 | # Clear data buffer. 932 | for y in list(self.data_buffer): 933 | self.data_buffer.pop(y, None) 934 | 935 | # Reset line_offset. 936 | pt_cursor_position.y = 0 937 | self.max_y = 0 938 | else: 939 | try: 940 | interval = ( 941 | # a) erase from cursor to the end of the display, including 942 | # the cursor, 943 | range(pt_cursor_position.y + 1, max_line + 1), 944 | # b) erase from the beginning of the display to the cursor, 945 | # including it, 946 | range(line_offset, pt_cursor_position.y), 947 | # c) erase the whole display. 948 | range(line_offset, max_line + 1), 949 | )[type_of] 950 | except IndexError: 951 | return 952 | 953 | data_buffer = self.data_buffer 954 | for line in interval: 955 | data_buffer[line] = defaultdict(lambda: Char(" ")) 956 | 957 | # In case of 0 or 1 we have to erase the line with the cursor. 958 | if type_of in [0, 1]: 959 | self.erase_in_line(type_of) 960 | 961 | def set_tab_stop(self) -> None: 962 | "Set a horizontal tab stop at cursor position." 963 | self.tabstops.add(self.pt_cursor_position.x) 964 | 965 | def clear_tab_stop(self, type_of: Optional[int] = None) -> None: 966 | """Clears a horizontal tab stop in a specific way, depending 967 | on the ``type_of`` value: 968 | * ``0`` or nothing -- Clears a horizontal tab stop at cursor 969 | position. 970 | * ``3`` -- Clears all horizontal tab stops. 971 | """ 972 | if not type_of: 973 | # Clears a horizontal tab stop at cursor position, if it's 974 | # present, or silently fails if otherwise. 975 | self.tabstops.discard(self.pt_cursor_position.x) 976 | elif type_of == 3: 977 | self.tabstops = set() # Clears all horizontal tab stops. 978 | 979 | def ensure_bounds(self, use_margins: Optional[bool] = None) -> None: 980 | """Ensure that current cursor position is within screen bounds. 981 | 982 | :param bool use_margins: when ``True`` or when 983 | :data:`~pyte.modes.DECOM` is set, 984 | cursor is bounded by top and and bottom 985 | margins, instead of ``[0; lines - 1]``. 986 | """ 987 | margins = self.margins 988 | if margins and (use_margins or mo.DECOM in self.mode): 989 | top, bottom = margins 990 | else: 991 | top, bottom = 0, self.lines - 1 992 | 993 | cursor_position = self.pt_cursor_position 994 | line_offset = self.line_offset 995 | 996 | cursor_position.x = min(max(0, cursor_position.x), self.columns - 1) 997 | cursor_position.y = min( 998 | max(top + line_offset, cursor_position.y), bottom + line_offset + 1 999 | ) 1000 | 1001 | def alignment_display(self) -> None: 1002 | for y in range(0, self.lines): 1003 | line = self.data_buffer[y + self.line_offset] 1004 | for x in range(0, self.columns): 1005 | line[x] = Char("E") 1006 | 1007 | # Mapping of the ANSI color codes to their names. 1008 | _fg_colors = {v: "#" + k for k, v in FG_ANSI_COLORS.items()} 1009 | _bg_colors = {v: "#" + k for k, v in BG_ANSI_COLORS.items()} 1010 | 1011 | # Mapping of the escape codes for 256colors to their '#ffffff' value. 1012 | _256_colors = {} 1013 | 1014 | for i, (r, g, b) in enumerate(_256_colors_table.colors): 1015 | _256_colors[1024 + i] = f"#{r:02x}{g:02x}{b:02x}" 1016 | 1017 | def select_graphic_rendition(self, *attrs_tuple: int, private: bool = False) -> None: 1018 | """Support 256 colours""" 1019 | replace: Dict[str, object] = {} 1020 | 1021 | if not attrs_tuple: 1022 | attrs = [0] 1023 | else: 1024 | attrs = list(attrs_tuple[::-1]) 1025 | 1026 | while attrs: 1027 | attr = attrs.pop() 1028 | 1029 | if attr in self._fg_colors: 1030 | replace["color"] = self._fg_colors[attr] 1031 | elif attr in self._bg_colors: 1032 | replace["bgcolor"] = self._bg_colors[attr] 1033 | elif attr == 1: 1034 | replace["bold"] = True 1035 | elif attr == 3: 1036 | replace["italic"] = True 1037 | elif attr == 4: 1038 | replace["underline"] = True 1039 | elif attr == 5: 1040 | replace["blink"] = True 1041 | elif attr == 6: 1042 | replace["blink"] = True # Fast blink. 1043 | elif attr == 7: 1044 | replace["reverse"] = True 1045 | elif attr == 8: 1046 | replace["hidden"] = True 1047 | elif attr == 22: 1048 | replace["bold"] = False 1049 | elif attr == 23: 1050 | replace["italic"] = False 1051 | elif attr == 24: 1052 | replace["underline"] = False 1053 | elif attr == 25: 1054 | replace["blink"] = False 1055 | elif attr == 27: 1056 | replace["reverse"] = False 1057 | elif not attr: 1058 | replace = {} 1059 | self._attrs = Attrs( 1060 | color=None, 1061 | bgcolor=None, 1062 | bold=False, 1063 | underline=False, 1064 | strike=False, 1065 | italic=False, 1066 | blink=False, 1067 | reverse=False, 1068 | hidden=False, 1069 | ) 1070 | 1071 | elif attr in (38, 48): 1072 | n = attrs.pop() 1073 | 1074 | # 256 colors. 1075 | if n == 5: 1076 | if attr == 38: 1077 | m = attrs.pop() 1078 | replace["color"] = self._256_colors.get(1024 + m) 1079 | elif attr == 48: 1080 | m = attrs.pop() 1081 | replace["bgcolor"] = self._256_colors.get(1024 + m) 1082 | 1083 | # True colors. 1084 | if n == 2: 1085 | try: 1086 | color_str = "#{:02x}{:02x}{:02x}".format( 1087 | attrs.pop(), 1088 | attrs.pop(), 1089 | attrs.pop(), 1090 | ) 1091 | except IndexError: 1092 | pass 1093 | else: 1094 | if attr == 38: 1095 | replace["color"] = color_str 1096 | elif attr == 48: 1097 | replace["bgcolor"] = color_str 1098 | 1099 | attrs_obj = self._attrs._replace(**replace) # type:ignore 1100 | 1101 | # Build style string. 1102 | style_str = "" 1103 | if attrs_obj.color: 1104 | style_str += "%s " % attrs_obj.color 1105 | if attrs_obj.bgcolor: 1106 | style_str += "bg:%s " % attrs_obj.bgcolor 1107 | if attrs_obj.bold: 1108 | style_str += "bold " 1109 | if attrs_obj.italic: 1110 | style_str += "italic " 1111 | if attrs_obj.underline: 1112 | style_str += "underline " 1113 | if attrs_obj.blink: 1114 | style_str += "blink " 1115 | if attrs_obj.reverse: 1116 | style_str += "reverse " 1117 | if attrs_obj.hidden: 1118 | style_str += "hidden " 1119 | 1120 | self._style_str = _unicode_intern_dict[style_str] 1121 | self._attrs = attrs_obj 1122 | 1123 | def report_device_status(self, data: int) -> None: 1124 | """ 1125 | Report cursor position. 1126 | """ 1127 | if data == 6: 1128 | y = self.pt_cursor_position.y - self.line_offset + 1 1129 | x = self.pt_cursor_position.x + 1 1130 | 1131 | response = "\x1b[%i;%iR" % (y, x) 1132 | self.write_process_input(response) 1133 | 1134 | def report_device_attributes(self, *args, **kwargs) -> None: 1135 | response = "\x1b[>84;0;0c" 1136 | self.write_process_input(response) 1137 | 1138 | def set_icon_name(self, param: str) -> None: 1139 | self.icon_name = param 1140 | 1141 | def set_title(self, param: str) -> None: 1142 | self.title = param 1143 | 1144 | def define_charset(self, *a, **kw): 1145 | pass 1146 | 1147 | def charset_default(self, *a, **kw): 1148 | "Not implemented." 1149 | 1150 | def charset_utf8(self, *a, **kw): 1151 | "Not implemented." 1152 | 1153 | def debug(self, *args, **kwargs): 1154 | pass 1155 | 1156 | def _reflow(self) -> None: 1157 | """ 1158 | Reflow the screen using the given width. 1159 | """ 1160 | width = self.columns 1161 | 1162 | data_buffer = self.pt_screen.data_buffer 1163 | new_data_buffer = Screen(default_char=Char(" ", "")).data_buffer 1164 | cursor_position = self.pt_cursor_position 1165 | cy, cx = (cursor_position.y, cursor_position.x) 1166 | 1167 | cursor_character = data_buffer[cursor_position.y][cursor_position.x].char 1168 | 1169 | # Ensure that the cursor position is present. 1170 | # (and avoid calling min() on empty collection.) 1171 | data_buffer[cursor_position.y][cursor_position.y] 1172 | 1173 | # Unwrap all the lines. 1174 | offset = min(data_buffer) 1175 | line: List[Char] = [] 1176 | all_lines: List[List[Char]] = [line] 1177 | 1178 | for row_index in range(min(data_buffer), max(data_buffer) + 1): 1179 | row = data_buffer[row_index] 1180 | 1181 | row[0] # Avoid calling max() on empty collection. 1182 | for column_index in range(0, max(row) + 1): 1183 | if cy == row_index and cx == column_index: 1184 | cy = len(all_lines) - 1 1185 | cx = len(line) 1186 | 1187 | line.append(row[column_index]) 1188 | 1189 | # Create new line if the next line was not a wrapped line. 1190 | if row_index + 1 not in self.wrapped_lines: 1191 | line = [] 1192 | all_lines.append(line) 1193 | 1194 | # Remove trailing whitespace (unless it contains the cursor). 1195 | # Also make sure that lines consist of at lesat one character, 1196 | # otherwise we can't calculate `max_y` correctly. (This is important 1197 | # for the `clear` command.) 1198 | for row_index, line in enumerate(all_lines): 1199 | # We do this only if no special styling given. 1200 | while len(line) > 1 and line[-1].char.isspace() and not line[-1].style: 1201 | if row_index == cy and len(line) - 1 == cx: 1202 | break 1203 | line.pop() 1204 | 1205 | # Wrap lines again according to the screen width. 1206 | new_row_index = offset 1207 | new_column_index = 0 1208 | new_wrapped_lines = [] 1209 | 1210 | for row_index, line in enumerate(all_lines): 1211 | for column_index, char in enumerate(line): 1212 | # Check for space on the current line. 1213 | if new_column_index + char.width > width: 1214 | new_row_index += 1 1215 | new_column_index = 0 1216 | new_wrapped_lines.append(new_row_index) 1217 | 1218 | if cy == row_index and cx == column_index: 1219 | cy = new_row_index 1220 | cx = new_column_index 1221 | 1222 | # Add character to new buffer. 1223 | new_data_buffer[new_row_index][new_column_index] = char 1224 | new_column_index += char.width 1225 | 1226 | new_row_index += 1 1227 | new_column_index = 0 1228 | 1229 | # TODO: when the window gets smaller, and the cursor is at the top of the screen, 1230 | # remove lines at the bottom. 1231 | for row_index in range(min(data_buffer), max(data_buffer) + 1): 1232 | if row_index > cy + self.lines: 1233 | del data_buffer[row_index] 1234 | 1235 | self.pt_screen.data_buffer = new_data_buffer 1236 | self.data_buffer = new_data_buffer 1237 | self.wrapped_lines = new_wrapped_lines 1238 | 1239 | cursor_position.y, cursor_position.x = cy, cx 1240 | self.pt_cursor_position = cursor_position 1241 | 1242 | # If everything goes well, the cursor should still be on the same character. 1243 | if ( 1244 | cursor_character 1245 | != new_data_buffer[cursor_position.y][cursor_position.x].char 1246 | ): 1247 | # FIXME: 1248 | raise Exception( 1249 | "Reflow failed: {!r} {!r}".format( 1250 | cursor_character, 1251 | new_data_buffer[cursor_position.y][cursor_position.x].char, 1252 | ) 1253 | ) 1254 | 1255 | self.max_y = max(self.data_buffer) 1256 | 1257 | self.max_y = min(self.max_y, cursor_position.y + self.lines - 1) 1258 | -------------------------------------------------------------------------------- /ptterm/stream.py: -------------------------------------------------------------------------------- 1 | """ 2 | Improvements on Pyte. 3 | """ 4 | from pyte.escape import NEL 5 | from pyte.streams import Stream 6 | 7 | __all__ = ("BetterStream",) 8 | 9 | 10 | class BetterStream(Stream): 11 | """ 12 | Extension to the Pyte `Stream` class that also handles "Esc]...BEL" 13 | sequences. This is used by xterm to set the terminal title. 14 | """ 15 | 16 | escape = Stream.escape.copy() 17 | escape.update( 18 | { 19 | # Call next_line instead of line_feed. We always want to go to the left 20 | # margin if we receive this, unlike \n, which goes one row down. 21 | # (Except when LNM has been set.) 22 | NEL: "next_line", 23 | } 24 | ) 25 | 26 | def __init__(self, screen) -> None: 27 | super().__init__() 28 | self.listener = screen 29 | self._validate_screen() 30 | 31 | def _validate_screen(self) -> None: 32 | """ 33 | Check whether our Screen class has all the required callbacks. 34 | (We want to verify this statically, before feeding content to the 35 | screen.) 36 | """ 37 | for d in [self.basic, self.escape, self.sharp, self.csi]: 38 | for name in d.values(): 39 | assert hasattr(self.listener, name), "Screen is missing %r" % name 40 | 41 | for d in ("define_charset", "set_icon_name", "set_title", "draw", "debug"): 42 | assert hasattr(self.listener, name), "Screen is missing %r" % name 43 | -------------------------------------------------------------------------------- /ptterm/terminal.py: -------------------------------------------------------------------------------- 1 | """ 2 | The layout engine. This builds the prompt_toolkit layout. 3 | """ 4 | from typing import Callable, Iterable, List, Optional 5 | 6 | from prompt_toolkit.application.current import get_app, get_app_or_none 7 | from prompt_toolkit.buffer import Buffer 8 | from prompt_toolkit.document import Document 9 | from prompt_toolkit.filters import Condition, has_selection 10 | from prompt_toolkit.formatted_text import StyleAndTextTuples 11 | from prompt_toolkit.key_binding import KeyBindings 12 | from prompt_toolkit.key_binding.key_processor import KeyPressEvent 13 | from prompt_toolkit.keys import Keys 14 | from prompt_toolkit.layout.containers import ( 15 | ConditionalContainer, 16 | Float, 17 | FloatContainer, 18 | HSplit, 19 | VSplit, 20 | Window, 21 | ) 22 | from prompt_toolkit.layout.controls import ( 23 | BufferControl, 24 | FormattedTextControl, 25 | UIContent, 26 | UIControl, 27 | ) 28 | from prompt_toolkit.layout.processors import ( 29 | HighlightIncrementalSearchProcessor, 30 | HighlightSearchProcessor, 31 | HighlightSelectionProcessor, 32 | Processor, 33 | Transformation, 34 | ) 35 | from prompt_toolkit.layout.screen import Point 36 | from prompt_toolkit.mouse_events import MouseEventType 37 | from prompt_toolkit.utils import Event, is_windows 38 | from prompt_toolkit.widgets.toolbars import SearchToolbar 39 | 40 | from .backends import Backend 41 | from .process import Process 42 | 43 | __all__ = ["Terminal"] 44 | 45 | E = KeyPressEvent 46 | 47 | 48 | class _TerminalControl(UIControl): 49 | def __init__( 50 | self, 51 | backend: Backend, 52 | done_callback: Optional[Callable[[], None]] = None, 53 | bell_func: Optional[Callable[[], None]] = None, 54 | ) -> None: 55 | def has_priority() -> bool: 56 | # Give priority to the processing of this terminal output, if this 57 | # user control has the focus. 58 | app_or_none = get_app_or_none() 59 | 60 | if app_or_none is None: 61 | # The application has terminated before this process ended. 62 | return False 63 | 64 | return app_or_none.layout.has_focus(self) 65 | 66 | self.process = Process( 67 | lambda: self.on_content_changed.fire(), 68 | backend=backend, 69 | done_callback=done_callback, 70 | bell_func=bell_func, 71 | has_priority=has_priority, 72 | ) 73 | 74 | self.on_content_changed = Event(self) 75 | self._running = False 76 | 77 | def create_content(self, width: int, height: int) -> UIContent: 78 | # Report dimensions to the process. 79 | self.process.set_size(width, height) 80 | 81 | # The first time that this user control is rendered. Keep track of the 82 | # 'app' object and start the process. 83 | if not self._running: 84 | self.process.start() 85 | self._running = True 86 | 87 | if not self.process.screen: 88 | return UIContent() 89 | 90 | pt_screen = self.process.screen.pt_screen 91 | pt_cursor_position = self.process.screen.pt_cursor_position 92 | data_buffer = pt_screen.data_buffer 93 | cursor_y = pt_cursor_position.y 94 | 95 | # Prompt_toolkit needs the amount of characters before the cursor in a 96 | # UIControl. This doesn't correspond with the xpos in case of double 97 | # width characters. That's why we compute the wcwidth. 98 | cursor_row = data_buffer[pt_cursor_position.y] 99 | text_before_cursor = "".join( 100 | cursor_row[x].char for x in range(0, pt_cursor_position.x) 101 | ) 102 | cursor_x = len(text_before_cursor) 103 | 104 | def get_line(number: int) -> StyleAndTextTuples: 105 | row = data_buffer[number] 106 | empty = True 107 | if row: 108 | max_column = max(row) 109 | empty = False 110 | else: 111 | max_column = 0 112 | 113 | if number == cursor_y: 114 | max_column = max(max_column, cursor_x) 115 | empty = False 116 | 117 | if empty: 118 | return [("", " ")] 119 | else: 120 | cells = [row[i] for i in range(max_column + 1)] 121 | return [(cell.style, cell.char) for cell in cells] 122 | 123 | if data_buffer: 124 | line_count = ( 125 | max(data_buffer) + 1 126 | ) # TODO: subtract all empty lines from the beginning. (If we need to. Not sure.) 127 | else: 128 | line_count = 1 129 | 130 | return UIContent( 131 | get_line, 132 | line_count=line_count, 133 | show_cursor=pt_screen.show_cursor, 134 | cursor_position=Point(x=cursor_x, y=cursor_y), 135 | ) 136 | 137 | def get_key_bindings(self) -> KeyBindings: 138 | bindings = KeyBindings() 139 | 140 | @bindings.add(Keys.Any) 141 | def handle_key(event): 142 | """ 143 | Handle any key binding -> write it to the stdin of this terminal. 144 | """ 145 | self.process.write_key(event.key_sequence[0].key) 146 | 147 | @bindings.add(Keys.BracketedPaste) 148 | def _(event): 149 | self.process.write_input(event.data, paste=True) 150 | 151 | return bindings 152 | 153 | def get_invalidate_events(self) -> Iterable[Event]: 154 | yield self.on_content_changed 155 | 156 | def mouse_handler(self, mouse_event) -> None: 157 | """ 158 | Handle mouse events in a pane. A click in a non-active pane will select 159 | it. A click in active pane will send the mouse event to the application 160 | running inside it. 161 | """ 162 | app = get_app() 163 | 164 | process = self.process 165 | x = mouse_event.position.x 166 | y = mouse_event.position.y 167 | 168 | # The containing Window translates coordinates to the absolute position 169 | # of the whole screen, but in this case, we need the relative 170 | # coordinates of the visible area. 171 | y -= self.process.screen.line_offset 172 | 173 | if not app.layout.has_focus(self): 174 | # Focus this process when the mouse has been clicked. 175 | if mouse_event.event_type == MouseEventType.MOUSE_UP: 176 | app.layout.focus(self) 177 | else: 178 | # Already focussed, send event to application when it requested 179 | # mouse support. 180 | if process.screen.sgr_mouse_support_enabled: 181 | # Xterm SGR mode. 182 | try: 183 | ev, m = { 184 | MouseEventType.MOUSE_DOWN: (0, "M"), 185 | MouseEventType.MOUSE_UP: (0, "m"), 186 | MouseEventType.SCROLL_UP: (64, "M"), 187 | MouseEventType.SCROLL_DOWN: (65, "M"), 188 | }[mouse_event.event_type] 189 | except KeyError: 190 | pass 191 | else: 192 | self.process.write_input(f"\x1b[<{ev};{x + 1};{y + 1}{m}") 193 | 194 | elif process.screen.urxvt_mouse_support_enabled: 195 | # Urxvt mode. 196 | try: 197 | ev = { 198 | MouseEventType.MOUSE_DOWN: 32, 199 | MouseEventType.MOUSE_UP: 35, 200 | MouseEventType.SCROLL_UP: 96, 201 | MouseEventType.SCROLL_DOWN: 97, 202 | }[mouse_event.event_type] 203 | except KeyError: 204 | pass 205 | else: 206 | self.process.write_input(f"\x1b[{ev};{x + 1};{y + 1}M") 207 | 208 | elif process.screen.mouse_support_enabled: 209 | # Fall back to old mode. 210 | if x < 96 and y < 96: 211 | try: 212 | ev = { 213 | MouseEventType.MOUSE_DOWN: 32, 214 | MouseEventType.MOUSE_UP: 35, 215 | MouseEventType.SCROLL_UP: 96, 216 | MouseEventType.SCROLL_DOWN: 97, 217 | }[mouse_event.event_type] 218 | except KeyError: 219 | pass 220 | else: 221 | self.process.write_input( 222 | f"\x1b[M{chr(ev)}{chr(x + 33)}{chr(y + 33)}" 223 | ) 224 | 225 | def is_focusable(self) -> bool: 226 | return not self.process.suspended 227 | 228 | 229 | class _Window(Window): 230 | """ """ 231 | 232 | def __init__(self, terminal_control: _TerminalControl, **kw) -> None: 233 | self.terminal_control = terminal_control 234 | super().__init__(**kw) 235 | 236 | def write_to_screen(self, *a, **kw) -> None: 237 | # Make sure that the bottom of the terminal is always visible. 238 | screen = self.terminal_control.process.screen 239 | 240 | # NOTE: the +1 is required because max_y starts counting at 0, while 241 | # lines counts the numbers of lines, starting at 1 for one line. 242 | self.vertical_scroll = screen.max_y - screen.lines + 1 243 | 244 | super().write_to_screen(*a, **kw) 245 | 246 | 247 | def create_backend( 248 | command: List[str], before_exec_func: Optional[Callable[[], None]] 249 | ) -> Backend: 250 | if is_windows(): 251 | from .backends.win32 import Win32Backend 252 | 253 | return Win32Backend() 254 | else: 255 | from .backends.posix import PosixBackend 256 | 257 | return PosixBackend.from_command(command, before_exec_func=before_exec_func) 258 | 259 | 260 | class Terminal: 261 | """ 262 | Terminal widget for use in a prompt_toolkit layout. 263 | 264 | :param command: List of command line arguments. 265 | For instance: `['python', '-c', 'print("test")']` 266 | :param before_exec_func: Function which is called in the child process, 267 | right before calling `exec`. Useful for instance for changing the 268 | current working directory or setting environment variables. 269 | """ 270 | 271 | def __init__( 272 | self, 273 | command=["/bin/bash"], 274 | before_exec_func=None, 275 | backend: Optional[Backend] = None, 276 | bell_func: Optional[Callable[[], None]] = None, 277 | style: str = "", 278 | width: Optional[int] = None, 279 | height: Optional[int] = None, 280 | done_callback: Optional[Callable[[], None]] = None, 281 | ) -> None: 282 | if backend is None: 283 | backend = create_backend(command, before_exec_func) 284 | 285 | self.terminal_control = _TerminalControl( 286 | backend=backend, bell_func=bell_func, done_callback=done_callback 287 | ) 288 | 289 | self.terminal_window = _Window( 290 | terminal_control=self.terminal_control, 291 | content=self.terminal_control, 292 | wrap_lines=False, 293 | ) 294 | 295 | # Key bindigns for copy buffer. 296 | kb = KeyBindings() 297 | 298 | @kb.add("c-c") 299 | def _exit(event): 300 | self.exit_copy_mode() 301 | 302 | @kb.add("space") 303 | def _reset_selection(event): 304 | "Reset selection." 305 | event.current_buffer.start_selection() 306 | 307 | @kb.add("enter", filter=has_selection) 308 | def _copy_selection(event): 309 | "Copy selection." 310 | data = event.current_buffer.copy_selection() 311 | event.app.clipboard.set_data(data) 312 | 313 | self.search_toolbar = SearchToolbar( 314 | forward_search_prompt="Search down: ", backward_search_prompt="Search up: " 315 | ) 316 | 317 | self.copy_buffer = Buffer(read_only=True) 318 | self.copy_buffer_control = BufferControl( 319 | buffer=self.copy_buffer, 320 | search_buffer_control=self.search_toolbar.control, 321 | include_default_input_processors=False, 322 | input_processors=[ 323 | _UseStyledTextProcessor(self), 324 | HighlightSelectionProcessor(), 325 | HighlightSearchProcessor(), 326 | HighlightIncrementalSearchProcessor(), 327 | ], 328 | preview_search=True, # XXX: not sure why we need twice preview_search. 329 | key_bindings=kb, 330 | ) 331 | 332 | self.copy_window = Window(content=self.copy_buffer_control, wrap_lines=False) 333 | 334 | self.is_copying = False 335 | 336 | @Condition 337 | def is_copying() -> bool: 338 | return self.is_copying 339 | 340 | self.container = FloatContainer( 341 | content=HSplit( 342 | [ 343 | # Either show terminal window or copy buffer. 344 | VSplit( 345 | [ # XXX: this nested VSplit should not have been necessary, 346 | # but the ConditionalContainer which width can become 347 | # zero will collapse the other elements. 348 | ConditionalContainer( 349 | self.terminal_window, filter=~is_copying 350 | ), 351 | ConditionalContainer(self.copy_window, filter=is_copying), 352 | ] 353 | ), 354 | ConditionalContainer(self.search_toolbar, filter=is_copying), 355 | ], 356 | style=style, 357 | width=width, 358 | height=height, 359 | ), 360 | floats=[ 361 | Float( 362 | top=0, 363 | right=0, 364 | height=1, 365 | content=ConditionalContainer( 366 | Window( 367 | content=FormattedTextControl( 368 | text=self._copy_position_formatted_text 369 | ), 370 | style="class:copy-mode-cursor-position", 371 | ), 372 | filter=is_copying, 373 | ), 374 | ) 375 | ], 376 | ) 377 | 378 | def _copy_position_formatted_text(self) -> str: 379 | """ 380 | Return the cursor position text to be displayed in copy mode. 381 | """ 382 | render_info = self.copy_window.render_info 383 | if render_info: 384 | return f"[{render_info.cursor_position.y + 1}/{render_info.content_height}]" 385 | else: 386 | return "[0/0]" 387 | 388 | def enter_copy_mode(self) -> None: 389 | # Suspend process. 390 | self.terminal_control.process.suspend() 391 | 392 | # Copy content into copy buffer. 393 | data_buffer = self.terminal_control.process.screen.pt_screen.data_buffer 394 | 395 | text = [] 396 | styled_lines = [] 397 | 398 | if data_buffer: 399 | for line_index in range(min(data_buffer), max(data_buffer) + 1): 400 | line = data_buffer[line_index] 401 | styled_line = [] 402 | 403 | if line: 404 | for column_index in range(0, max(line) + 1): 405 | char = line[column_index] 406 | text.append(char.char) 407 | styled_line.append((char.style, char.char)) 408 | 409 | text.append("\n") 410 | styled_lines.append(styled_line) 411 | text.pop() # Drop last line ending. 412 | 413 | text_str = "".join(text) 414 | 415 | self.copy_buffer.set_document( 416 | Document(text=text_str, cursor_position=len(text_str)), bypass_readonly=True 417 | ) 418 | 419 | self.styled_lines = styled_lines 420 | 421 | # Enter copy mode. 422 | self.is_copying = True 423 | get_app().layout.focus(self.copy_window) 424 | 425 | def exit_copy_mode(self) -> None: 426 | # Resume process. 427 | self.terminal_control.process.resume() 428 | 429 | # focus terminal again. 430 | self.is_copying = False 431 | get_app().layout.focus(self.terminal_window) 432 | 433 | def __pt_container__(self) -> FloatContainer: 434 | return self.container 435 | 436 | @property 437 | def process(self): 438 | return self.terminal_control.process 439 | 440 | 441 | class _UseStyledTextProcessor(Processor): 442 | """ 443 | In order to allow highlighting of the copy region, we use a preprocessed 444 | list of (style, text) tuples. This processor returns just that list for the 445 | given pane. 446 | 447 | This processor should go before all others, because it replaces the list of 448 | (style, text) tuples. 449 | """ 450 | 451 | def __init__(self, terminal: Terminal) -> None: 452 | self.terminal = terminal 453 | 454 | def apply_transformation(self, transformation_input) -> Transformation: 455 | try: 456 | line = self.terminal.styled_lines[transformation_input.lineno] 457 | except IndexError: 458 | line = [] 459 | return Transformation(line) 460 | -------------------------------------------------------------------------------- /ptterm/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Some utilities. 3 | """ 4 | import getpass 5 | import os 6 | 7 | __all__ = ("get_default_shell",) 8 | 9 | 10 | def get_default_shell() -> str: 11 | """ 12 | return the path to the default shell for the current user. 13 | """ 14 | import pwd # XXX: Posix only. 15 | 16 | if "SHELL" in os.environ: 17 | return os.environ["SHELL"] 18 | else: 19 | username = getpass.getuser() 20 | shell = pwd.getpwnam(username).pw_shell 21 | return shell 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | target-version = "py37" 3 | select = [ 4 | "E", # pycodestyle errors 5 | "W", # pycodestyle warnings 6 | "F", # pyflakes 7 | "C", # flake8-comprehensions 8 | "T", # Print. 9 | "I", # isort 10 | # "B", # flake8-bugbear 11 | "UP", # pyupgrade 12 | "RUF100", # unused-noqa 13 | "Q", # quotes 14 | ] 15 | ignore = [ 16 | "E501", # Line too long, handled by black 17 | "C901", # Too complex 18 | "E722", # bare except. 19 | ] 20 | 21 | 22 | [tool.ruff.per-file-ignores] 23 | 24 | 25 | [tool.ruff.isort] 26 | known-first-party = ["ptterm"] 27 | known-third-party = ["prompt_toolkit"] 28 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | from setuptools import find_packages, setup 6 | 7 | with open(os.path.join(os.path.dirname(__file__), "README.rst")) as f: 8 | long_description = f.read() 9 | 10 | requirements = [ 11 | "prompt_toolkit>=3.0.0,<3.1.0", 12 | "pyte>=0.5.1", 13 | ] 14 | 15 | # Install yawinpty on Windows only. 16 | if sys.platform.startswith("win"): 17 | requirements.append("yawinpty") 18 | 19 | 20 | setup( 21 | name="ptterm", 22 | author="Jonathan Slenders", 23 | version="0.1", 24 | license="LICENSE", 25 | url="https://github.com/jonathanslenders/ptterm", 26 | description="Terminal emulator for prompt_toolkit.", 27 | long_description=long_description, 28 | packages=find_packages("."), 29 | install_requires=requirements, 30 | ) 31 | --------------------------------------------------------------------------------