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.


    UPDATE 2025-06-08

    I took a look at your updates, and I attempted to recreate it using the same libraries. Given the way those libraries work, I believe your best chance is to use the threading library. By checking for user input in a separate thread you can allow the main display thread to continue running.

    I feel obligated to mention, I highly recommend considering curses as I showed in the above answer, it is specifically designed to do this type of thing.

    Here is the code, which is mostly your provided code, a couple functions you didn't provide that I guessed at, and a couple alterations to make update between user inputs:

    import tty
    import termios
    import sys
    import string # Added?
    import os # Added?
    import shutil # Added?
    import time # Added
    import threading # Added
    import queue # Added
    
    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
    
    def set_visible(text: str, placeholder: str) -> str:
        visible_length = len(placeholder)
        cursor_char = '_' if ((time.time() - start_time) % blink_period) / blink_period < 0.5 else '█'  # Added
        if not text:
            return cursor_char + placeholder  # Edited
        else:
            if len(text) > visible_length:
                return text[-visible_length:] + cursor_char  # Edited
            else:
                return text[:] + cursor_char  # Edited
    
    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
    
    def username() -> str:
    # starting of my code....
    
        # New user inputs will be added to this queue by the other thread
        input_queue = queue.Queue()  # Added
        # Create a new thread to collect inputs
        input_thread = threading.Thread(target=collect_inputs, args=(input_queue,))  # Added
        # Start the other thread
        input_thread.start()  # Added
        name = ''  # Added?
        while 1:
    
            scr_width, scr_height = shutil.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()  # Edited
            # Added
            try:
                # Grab a key input from the input queue without waiting
                key = input_queue.get(False)
            except queue.Empty:
                # If no input was available, set key to empty string to handle specially
                key = ''
            # Added
            if key == '':
                # Wait a moment in this thread then recheck
                time.sleep(0.05)
                continue
    
            if key == '\x7f':
                os.system('clear')
                if name:
                    name = name[:-1]
            elif key == '\r' or key == '\n':
                if name:
                    break
            elif type(key) is str and key in string.ascii_letters + string.digits + ' ' + string.punctuation: # Edited
                os.system('clear')
                name += key
            else:
                os.system('clear')
            # rest of my code
        input_thread.join()  # Added
    
    # Added?
    def make_screen(scr_width: int, scr_height: int) -> list[list[str]]:
        out_val = [[' ' for _ in range(scr_width)] for _ in range(scr_height)]
        make_border(out_val)
        return out_val
    
    # Added?
    def make_border(box: list[list[str]]) -> None:
        for row_num in range(len(box)):
            if row_num == 0 or row_num == len(box) - 1:
                for col_num in range(len(box[row_num])):
                    if col_num == 0 or col_num == len(box[row_num]) - 1:
                        box[row_num][col_num] = '+'
                    else:
                        box[row_num][col_num] = '-'
            else:
                box[row_num][0] = '|'
                box[row_num][-1] = '|'
    
    # Added?
    def insert_text(frame: list[list[str]], text: str, row: int, col: int) -> None:
        for char_pos, char in enumerate(text):
            if char_pos + col >= len(frame[0]):
                break
            frame[row][col + char_pos] = char
    
    # Added?
    def print_screen(frame: list[list[str]]) -> None:
        global prev_frame
        if prev_frame == frame:
            return
        # os.system('clear')
        prev_frame = frame
        for line in frame:
            print(''.join(line), flush=True, end='')
    
    # Added
    def collect_inputs(out_queue: queue.Queue):
        # Continue looking for new inputs
        while True:
            # Grab a key
            new_key = get_key()
            # If a key was provided, send to the queue for the other process
            if new_key:
                out_queue.put(new_key)
            # If this was the last key of input, stop getting inputs
            if new_key in ['\r', '\n']:
                break
    
    prev_frame = None
    start_time = time.time()
    blink_period = 1.0
    username()
    

    Here is short recording showing the blinking cursor in action in a Ubuntu terminal:

    A GIF showing blinking cursor in an Ubuntu terminal