1

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 ('█')?

4
  • Maybe input() could do that for you but it will require submitting by hitting ENTER instead of only reading the key. Commented May 9, 2025 at 11:15
  • No, input will not simulate a textbox in a terminal, it will just prompt you. But I want GUI + Terminal = TUI, I'm trying to replicate textboxes and buttons into terminal. Commented May 9, 2025 at 16:03
  • I have been looking into this and I am having difficulty getting the inputs library to work at all, their own examples do not execute as provided. Can you provide a MRE showing the get_key method working? Second, are you open to using other libraries or are you committed to inputs? Commented May 26, 2025 at 23:57
  • As far as I can tell, the library has had one commit in the last 7 years, and surprisingly, it was 2 hours ago. The library may be outdated. Commented May 27, 2025 at 0:03

1 Answer 1

1

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

Sign up to request clarification or add additional context in comments.

2 Comments

Now, I have provided all the information that is essential in the latest edit. I've added the get_key and insert_text_in_box function definition. You can check if they help. And yes, thankyou very much for your original answer using curses, I can see you've done a lot of hardwork!
I know you already accepted the answer, but I have update it to include a section which is a closer match to your provided code. I had to take some liberties to fill in the missing segments. I still recommend you look into curses, but this should meet your needs even if you do not. Let me know if you have any more questions.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.