Search code examples
pythonterminalspydercursesansi-escape

How to write a Python terminal application with a fixed input line?


I'm trying to write a terminal application to interact with an Arduino microcontroller via pyserial. The following features are important:

  • Print incoming messages to the command line.
  • Allow the user to enter output messages to the serial port. The input should be possible, while new incoming messages are printed.

In principle, this should be possible with cmd. But I'm struggling with printing incoming messages, when the user started typing.

For simplicity, I wrote the following test script emulating incoming messages every second. Outgoing messages are just echoed back to the command line with the prefix ">":

#!/usr/bin/env python3
from cmd import Cmd
from threading import Thread
import time

class Prompt(Cmd):

    def default(self, inp):

        print('>', inp)

stop = False

def echo():
    
    while not stop:
        
        print(time.time())
        time.sleep(1)

thread = Thread(target=echo)
thread.daemon = True
thread.start()

try:
    Prompt().cmdloop()
except KeyboardInterrupt:
    stop = True
    thread.join()

In Spyder IDE, the result is just perfect:

Result in Spyder IDE

But in iterm2 (Mac OS) the output is pretty messed up:

Result in iterm2

Since I want to use this application from within Visual Studio Code, it should work outside Spyder. Do you have any idea how to get the same behaviour in iterm2 as in Spyder?

Things I already considered or tried out:

  • Use the curses library. This solves my problem of printing text to different regions. But I'm loosing endless scrolling, since curses defines its own fullscreen window.

  • Move the cursor using ansi escape sequences. It might be a possible solution, but I'm just not getting it to work. It always destroys the bottom line where the user is typing. I might need to adjust the scrolling region, which I still didn't manage to do.

  • Use a different interpreter. I already tried Python vs. iPython, without success. It might be a more subtle setting in Spyder's interpreter.


Solution

  • Yes! I found a solution: The Prompt Toolkit 3.0 in combination with asyncio lets you handle this very problem using patch_stdout, "a context manager that ensures that print statements within it won’t destroy the user interface".

    Here is a minimum working example:

    #!/usr/bin/env python3
    from prompt_toolkit import PromptSession
    from prompt_toolkit.patch_stdout import patch_stdout
    import asyncio
    import time
    
    async def echo():
    
        while True:
            
            print(time.time())
            await asyncio.sleep(1)
    
    async def read():
    
        session = PromptSession()
    
        while True:
     
            with patch_stdout():
                line = await session.prompt_async("> ")
                print(line.upper())
    
    loop = asyncio.get_event_loop()
    loop.create_task(echo())
    loop.create_task(read())
    loop.run_forever()
    

    screencast