python-3.xterminaltty

How do I make a blinking cursor in Python?


I'm developing a game for terminal, it takes inputs, so for taking input, it uses a get_key() which waits until user presses a key and then it returns the key pressed, it works similar to python's input() function but without a prompt. So what the player sees is:

+-----------------------------------------------------------+
|                    game x.x.x(version)                    |
|                                                           |
|              +----------------------------+               |
|              |█Placeholder...             |               |
|              +----------------------------+               |
|                                                           |
+-----------------------------------------------------------+

How I'm currently doing it:

Screen is a 2-dimensional array (list of lists) of size width X height. I make changes in the array using screen[row][col], clear the screen and reprint it.

What I want to do:

I want to simulate a text box. It works but the cursor is not blinking. I tried different methods but it messes up the whole mechanism. So I have a static cursor now.

How does it look now:

It takes one key you press. And adds it to the visible text, then it clears the screen and reprints it after inserting the visible text in a box, inside the screen, using a insert_text_in_box() function. Then repeat until you press Enter.

Here's how I've implemented the get_key() function:

import tty
import termios
import sys

def get_key() -> str:
    fd = sys.stdin.fileno()
    old_settings = termios.tcgetattr(fd)

    try:
        tty.setraw(fd)             # Turn off buffering
        key = sys.stdin.read(1)    # Read 1 character
    finally:
        termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)

    return key

But, it is taking each key input and displaying it inside the text box, this is the function that I'm using to achieve this functionality:

def set_visible(text: str, placeholder: str) -> str:
    visible_length = len(placeholder)
    if not text:
        return '█' + placeholder
    else:
        if len(text) > visible_length:
            return text[-visible_length:] + '█'
        else:
            return text[:] + '█'

And it uses a while loop with os.system('clear') to clear and reprint the screen.
The text box logic is like:

# existing code.....


def insert_text_in_box(frame: list[list[str]], text: str, box_row: int, box_col: int, box_width: int, box_height: int) -> None:
    box = [[' ' for _ in range(box_width)] for _ in range(box_height)]
    make_border(box)
    insert_text(box, text, 1, 1)
    for i in range(box_height):
        for j in range(box_width):
            frame[box_row + i][box_col + j] = box[i][j]
    del box


# existing code....



def username() -> str:
# starting of my code....



 while 1:

        scr_width, scr_height = get_terminal_size(fallback=(80, 24))

        screen = make_screen(scr_width, scr_height)
        visible = set_visible(name, 'Enter you username...')
        insert_text_in_box(frame=screen, text=visible, box_row=len(screen) //
                           2 - 1, box_col=len(screen[0])//2 - 12, box_width=24, box_height=3)

        insert_text(frame=screen, text="Username:", row=len(
            screen)//2 - 2, col=len(screen[0])//2 - 4)

        print_screen(screen)
        key = get_key()
        if key == '\x7f':
            os.system('clear')
            if name:
                name = name[:-1]
        elif key == '\r' or key == '\n':
            if name:
                break
        elif key in ascii_letters + digits + ' ' + punctuation:
            os.system('clear')
            name += key
        else:
            os.system('clear')
        # rest of my code

Now the thing is, I can't make the cursor blink, since get_key blocks execution of the code below it until a key is pressed.

I'm using the tty and termios libraries to integrate my python script with the terminal.

How can I make the a blinking cursor ('█')?


Solution

  • The main issue you are going to face in general is that input is usually blocking, so the script pauses while it waits for the user's input, making the blinking more challenging. However, there are libraries which support non-blocking input, including curses which I have a brief example of here, but there are others as well. As I mentioned in the comments, I was unable to get inputs to work at all, even with the example code from their documentation, which is why the example here is curses though it can be reworked for any non-blocking input.

    They key component here is to keep track of the time, and each time you redraw the screen check the time to see if the cursor should be in state 1 or state 2. The logic I used can be seen in the block with the cursorchar assignment.

    import curses
    import time
    
    class MyTermDisplay:
        # How frequently to blink the cursor
        BLINK_PERIOD = 0.75
        def __init__(self):
            # Store the last three inputs to show in the screen
            self._last_three = []
            # The start time is used to calculate the blinking cursor
            self._start_time = time.perf_counter()
            # Pre-initialize a variable to store user input
            self._current_input = ''
    
        def _get_box(self) -> list[str]:
            # Get the characters for the box on the screen
    
            # Get the size of the box every iteration so that it resizes if the window does
            curses.update_lines_cols()
            x, y = curses.COLS, curses.LINES
            draw_str: list[str] = []
            # Top bar
            draw_str.append('-' * x)
            # Cycle through each interior line
            for i in range(y - 3):
                # If we have a previous input for this line, put it
                if len(self._last_three) > i:
                    # Truncate the line to fit in the space provided
                    prev_input = self._last_three[i][:x - 4]
                    # Draw the left bar, the text, then the filler spaces, then the right bar
                    draw_str.append('| ' + prev_input + (x - len(prev_input) - 4) * ' ' + ' |')
                else:
                    # If there was no input for this line, just space filler
                    draw_str.append('| ' + ' ' * (x - 4) + ' |')
            # Which cursor to use
            #   If we are in the first half of the period, coloured bar
            #   If we are in the second half of the period, underscore
            if ((time.perf_counter() - self._start_time) % MyTermDisplay.BLINK_PERIOD) / MyTermDisplay.BLINK_PERIOD < 0.5:
                cursorchar = '█'
            else:
                cursorchar = '_'
            # Put left bar, the input with the cursor on the end plus any filler spaces and right bar
            draw_str.append('| >> ' + self._current_input[:x - 8] + cursorchar + (x - len(self._current_input[:x - 8]) - 7) * ' ' + '|')
            # Bottom bar (curses doesn't like to write to last char, so its truncated here)
            draw_str.append('-' * (x - 1) + '')
            return draw_str
    
        def _process_inputs(self, stdscr: curses.window):
            # Check for new keys
            #   Because this is in nodelay, this can throw an error
            new_key = None
            try:
                new_key = stdscr.getkey()
            except curses.error:
                # No key press
                pass
            # No key press
            if new_key is None:
                return True
            # If the user presses escape, break the loop gracefully
            if new_key == chr(27):
                return False
            # Enter and numpad-enter
            if new_key in [chr(10), 'PADENTER']:
                # Add the current input the input history and reset it
                self._last_three.append(self._current_input)
                # Truncate the input history to 3 max length
                self._last_three = self._last_three[-3:]
                self._current_input = ''
                return True
            # Backspace
            if new_key == chr(8):
                # Handle the backspace by removing a character if present
                if len(self._current_input):
                    self._current_input = self._current_input[:-1]
                return True
            # Ignore resize events
            if new_key == 'KEY_RESIZE':
                return True
    
            # Debugging conveniences for the developer
            #   All otherwise unhandled keys come here
            print(f'"{new_key}" - ', end=' ')
            if len(new_key) == 1:
                print('CHR:', ord(new_key))
            else:
                print('LEN: ', len(new_key))
            # Add this character to the input string
            self._current_input += new_key
            return True
    
    
        def start(self):
            curses.wrapper(self._start_fun)
    
        def _start_fun(self, stdscr: curses.window):
            # Hide the default terminal cursor
            curses.curs_set(False)
            # Clear the screen
            stdscr.clear()
            # Allow getkey to return instantly
            stdscr.nodelay(True)
            # A variable to control the while loop
            continue_loop = True
            while continue_loop:
                # A brief pause to avoid locking up
                time.sleep(0.01)
                # Write each line to the terminal
                for linenum, line in enumerate(self._get_box()):
                    stdscr.addstr(linenum, 0, line)
                # Redraw the screen
                stdscr.refresh()
    
                # Process any user inputs
                #   This function returns False if user requested a stop
                continue_loop = self._process_inputs(stdscr)
    
    
    def _main():
        # Initialize and start it
        mtd = MyTermDisplay()
        mtd.start()
    
    if __name__ == '__main__':
        _main()
    

    Here is a short recording of that program being used:

    A GIF showing how the terminal updates, including flashing cursor and resizing

    The blinking looks a little inconsistent because stackoverflow has strange frame-count restrictions on GIFs so the frame rate is only 7 FPS, but the overall functionality can still be seen.

    Let me know if you have any questions.