jquast / blessed

Blessed is an easy, practical library for making python terminal apps
http://pypi.python.org/pypi/blessed
MIT License
1.18k stars 71 forks source link

fix Terminal.getch() #264

Closed huyi51462 closed 7 months ago

huyi51462 commented 7 months ago

On the Windows platform, repeatedly entering the cbreak context environment can lead to inkey incorrectly interpreting keyboard input. In my project, for instance, inkey might misinterpret the Escape key as Enter. The solution is to differentiate the underlying getch function, using msvcrt.getch() specifically on Windows.

avylove commented 7 months ago

Do you have some example code we can try out? I haven't seen this issue and I'd like to understand better why it is occurring.

huyi51462 commented 7 months ago
from blessed import Terminal
import sys

Term = Terminal()
with Term.fullscreen():
    print("start")
    while True:
        with Term.cbreak(), Term.hidden_cursor():
            print("input_1 : ")
            key = Term.inkey()
            kc = key.code
            if key.code == Term.KEY_ENTER:
                print("Entry")
                pass
            else:
                if key.code == Term.KEY_ESCAPE:
                    print("Esc")
                    break
                continue
        try:
            print("input_2 : ")
            key_1 = sys.stdin.readline().strip()
            raise
        except Exception as e:
            print(f"error : {e}")
            print("input_3 : ")
            key_2 = Term.inkey()
            continue

屏幕截图 2024-01-23 102347

I debugged this piece of code on Windows 10 (virtual machine) and Windows 11 (physical machine) using VSCode. I entered Enter in input_1 to go into input_2, input some content, triggering an exception. The program ran to input_3, then pressed Enter again to return to input_1. At this point, pressing Esc resulted in an Enter.

This code is the framework for browsing and selecting in my project. It looks a bit odd because some logical parts have been removed, but its actual purpose is for segmenting browsing through a list. input_1 is used to receive page-turning keys, and after pressing Enter, it goes into input_2, which is the selection mode. If there's an error in the selection, it outputs an error message, runs to input_3, where you can input any key, and then loops back again.

I don't know exactly what's happening at the low level. When debugging and tracing into os.read() within Terminal.getch(), I sensed that it might be an issue with the cross-platform library os, as I've encountered some indescribable bugs with this library in other contexts. Therefore, I opted to replace it with msvcrt, and it worked. If you have insights into what's happening at the low level, I'd appreciate your guidance.

jquast commented 7 months ago

I think this is because the try/exception clause is outside of the cbreak context manager, but Term.inkey() is used in the exception block.

inkey() will return only the first character received, but because it is outside of the cbreak context manager it will need a return key to complete, and the return key is buffered until next call to inkey().

huyi51462 commented 7 months ago

inkey() will return only the first character received, but because it is outside of the cbreak context manager it will need a return key to complete, and the return key is buffered until next call to inkey().

Um... I tried again,buff meaning Terminal._keyboard_buf? In the inkey call that triggered the error, it's empty, and the variable 'usc' is also empty. The error 'enter' comes from inkey() -> getch() -> os.read(keyboard_fd, 1). It seems like it's reading a byte from the keyboard device. Is the 'buff' you're referring to related to the keyboard device?

Also, I tested the above code directly in the Terminal(Powershell / Cmd), not the terminal invoked by vscode, and no errors occurred. This issue might be related to the terminal calls in vscode.

jquast commented 7 months ago

the "buffer" in this case is the terminal. By default, stdin is "line-buffered", so input is not "sent" to the application until return key is pressed.

By reading just one byte from stdin, but without cbreak activated, it leaves the remaining bytes in the file descriptor (stdin, 0). Here is a brief example:

>>> import os
>>> os.read(0, 1)
abcd
b'a'
>>> bcd
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'bcd' is not defined

In this case, I instruct to read one byte from stdin, I enter 'abcd\<return>'. The byte 'a' is received, and bytes 'bcd\<return>' remain in stdin, which the python REPL then reads (as an "invalid command").

jquast commented 7 months ago

Here is a rudimentary "readline" example function you could use to stay in cbreak() mode while also providing line input,

https://github.com/jquast/blessed/blob/c28b53fccab1c1966e06c31f58a44b9359838711/bin/editor.py#L36

https://github.com/jquast/blessed/blob/c28b53fccab1c1966e06c31f58a44b9359838711/bin/editor.py#L72-L93

huyi51462 commented 7 months ago

the "buffer" in this case is the terminal. By default, stdin is "line-buffered", so input is not "sent" to the application until return key is pressed.

By reading just one byte from stdin, but without cbreak activated, it leaves the remaining bytes in the file descriptor (stdin, 0). Here is a brief example:

>>> import os
>>> os.read(0, 1)
abcd
b'a'
>>> bcd
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'bcd' is not defined

In this case, I instruct to read one byte from stdin, I enter 'abcd'. The byte 'a' is received, and bytes 'bcd' remain in stdin, which the python REPL then reads (as an "invalid command").

Thank you for the detailed explanation. I understand the meaning of the buffer, and I get what you said about 'outside of cbreak, inkey reads only one byte and caches the remaining content.' However, at the input_3 test point, I simply pressed enter once without any other input, and inkey captured this operation. Since key_2 also clearly received "\n," I believe the real issue may not lie here.

huyi51462 commented 7 months ago

Here is a rudimentary "readline" example function you could use to stay in cbreak() mode while also providing line input,

https://github.com/jquast/blessed/blob/c28b53fccab1c1966e06c31f58a44b9359838711/bin/editor.py#L36

https://github.com/jquast/blessed/blob/c28b53fccab1c1966e06c31f58a44b9359838711/bin/editor.py#L72-L93

Sure,got it!