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