Python curses, Part 3: Working With Windowed Content
Explore window customization, content updates, window size management, and CPU optimization with Python's curses module for efficient terminal interfaces.
Join the DZone community and get the full member experience.
Join For FreeWelcome back to the third — and final — installment in our series on how to work with the curses
library in Python to draw with text. If you missed the first two parts of this programming tutorial series — or if you wish to reference the code contained within them — you can read them here:
- "Python curses, Part 1: Drawing With Text"
- "Python curses, Part 2: How to Create a Python curses-Enabled Application"
Once reviewed, let’s move on to the next portion: how to decorate windows with borders and boxes using Python’s curses
module.
Decorating Windows With Borders and Boxes
Windows can be decorated using custom values as well as a default “box” adornment in Python. This can be accomplished using the window.box()
and window.border(…)
functions. The Python code example below creates a red 5×5 window and then alternates displaying and clearing the border on each key press:
# demo-window-border.py
import curses
import math
import sys
def main(argv):
# BEGIN ncurses startup/initialization...
# Initialize the curses object.
stdscr = curses.initscr()
# Do not echo keys back to the client.
curses.noecho()
# Non-blocking or cbreak mode... do not wait for Enter key to be pressed.
curses.cbreak()
# Turn off blinking cursor
curses.curs_set(False)
# Enable color if we can...
if curses.has_colors():
curses.start_color()
# Optional - Enable the keypad. This also decodes multi-byte key sequences
# stdscr.keypad(True)
# END ncurses startup/initialization...
caughtExceptions = ""
try:
# Create a 5x5 window in the center of the terminal window, and then
# alternate displaying a border and not on each key press.
# We don't need to know where the approximate center of the terminal
# is, but we do need to use the curses terminal size constants to
# calculate the X, Y coordinates of where we can place the window in
# order for it to be roughly centered.
topMostY = math.floor((curses.LINES - 5)/2)
leftMostX = math.floor((curses.COLS - 5)/2)
# Place a caption at the bottom left of the terminal indicating
# action keys.
stdscr.addstr (curses.LINES-1, 0, "Press Q to quit, any other key to alternate.")
stdscr.refresh()
# We're just using white on red for the window here:
curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_RED)
index = 0
done = False
while False == done:
# If we're on the first iteration, let's skip straight to creating the window.
if 0 != index:
# Grabs a value from the keyboard without Enter having to be pressed.
ch = stdscr.getch()
# Need to match on both upper-case or lower-case Q:
if ch == ord('Q') or ch == ord('q'):
done = True
mainWindow = curses.newwin(5, 5, topMostY, leftMostX)
mainWindow.bkgd(' ', curses.color_pair(1))
if 0 == index % 2:
mainWindow.box()
else:
# There's no way to "unbox," so blank out the border instead.
mainWindow.border(' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ')
mainWindow.refresh()
stdscr.addstr(0, 0, "Iteration [" + str(index) + "]")
stdscr.refresh()
index = 1 + index
except Exception as err:
# Just printing from here will not work, as the program is still set to
# use ncurses.
# print ("Some error [" + str(err) + "] occurred.")
caughtExceptions = str(err)
# BEGIN ncurses shutdown/deinitialization...
# Turn off cbreak mode...
curses.nocbreak()
# Turn echo back on.
curses.echo()
# Restore cursor blinking.
curses.curs_set(True)
# Turn off the keypad...
# stdscr.keypad(False)
# Restore Terminal to original state.
curses.endwin()
# END ncurses shutdown/deinitialization...
# Display Errors if any happened:
if "" != caughtExceptions:
print ("Got error(s) [" + caughtExceptions + "]")
if __name__ == "__main__":
main(sys.argv[1:])
This code was run over an SSH connection, so there is an automatic clearing of the screen upon its completion. The border “crops” the inside of the window, and any text that is placed within the window must be adjusted accordingly. And as the call to the window.border(…)
function suggests, any character can be used for the border.
The code works by waiting for a key to be pressed. If either Q or Shift+Q is pressed, the termination condition of the loop will be activated and the program will quit. Note that, pressing the arrow keys may return key presses and skip iterations.
How to Update Content in “Windows” With Python curses
Just as is the case with traditional graphical windowed programs, the text content of a curses window
can be changed. And just as is the case with graphical windowed programs, the old content of the window must be “blanked out” before any new content can be placed in the window.
The Python code example below demonstrates a digital clock that is centered on the screen. It makes use of Python lists to store sets of characters which when displayed, look like large versions of digits.
A brief note: The code below is not intended to be the most efficient means of displaying a clock; rather, it is intended to be a more portable demonstration of how curses
windows are updated.
# demo-clock.py
# These list assignments can be done on single lines, but it's much easier to see what
# these values represent by doing it this way.
space = [
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" ",
" "]
colon = [
" ",
" ",
" ::: ",
" ::: ",
" ",
" ",
" ::: ",
" ::: ",
" ",
" "]
forwardSlash = [
" ",
" //",
" // ",
" // ",
" // ",
" // ",
" // ",
" // ",
"// ",
" "]
number0 = [
" 000000 ",
" 00 00 ",
" 00 00 ",
" 00 00 ",
" 00 00 ",
" 00 00 ",
" 00 00 ",
" 00 00 ",
" 00 00 ",
" 000000 "]
number1 = [
" 11 ",
" 111 ",
" 1111 ",
" 11 ",
" 11 ",
" 11 ",
" 11 ",
" 11 ",
" 11 ",
" 111111 "]
number2 = [
" 222222 ",
" 22 22 ",
" 22 22 ",
" 22 ",
" 22 ",
" 22 ",
" 22 ",
" 22 ",
" 22 ",
" 22222222 "]
number3 = [
" 333333 ",
" 33 33 ",
" 33 33 ",
" 33 ",
" 3333 ",
" 33 ",
" 33 ",
" 33 33 ",
" 33 33 ",
" 333333 "]
number4 = [
" 44 ",
" 444 ",
" 4444 ",
" 44 44 ",
" 44 44 ",
"444444444 ",
" 44 ",
" 44 ",
" 44 ",
" 44 "]
number5 = [
" 55555555 ",
" 55 ",
" 55 ",
" 55 ",
" 55555555 ",
" 55 ",
" 55 ",
" 55 ",
" 55 ",
" 55555555 "]
number6 = [
" 666666 ",
" 66 66 ",
" 66 ",
" 66 ",
" 6666666 ",
" 66 66 ",
" 66 66 ",
" 66 66 ",
" 66 66 ",
" 666666 "]
number7 = [
" 77777777 ",
" 77 ",
" 77 ",
" 77 ",
" 77 ",
" 77 ",
" 77 ",
" 77 ",
" 77 ",
" 77 "]
number8 = [
" 888888 ",
" 88 88 ",
" 88 88 ",
" 88 88 ",
" 888888 ",
" 88 88 ",
" 88 88 ",
" 88 88 ",
" 88 88 ",
" 888888 "]
number9 = [
" 999999 ",
" 99 99 ",
" 99 99 ",
" 99 99 ",
" 999999 ",
" 99 ",
" 99 ",
" 99 ",
" 99 99 ",
" 999999 "]
import curses
import math
import sys
import datetime
def putChar(windowObj, inChar, inAttr = 0):
#windowObj.box()
#windowObj.addstr(inChar)
# The logic below maps the normal character input to a list which contains a "big"
# representation of that character.
charToPut = ""
if '0' == inChar:
charToPut = number0
elif '1' == inChar:
charToPut = number1
elif '2' == inChar:
charToPut = number2
elif '3' == inChar:
charToPut = number3
elif '4' == inChar:
charToPut = number4
elif '5' == inChar:
charToPut = number5
elif '6' == inChar:
charToPut = number6
elif '7' == inChar:
charToPut = number7
elif '8' == inChar:
charToPut = number8
elif '9' == inChar:
charToPut = number9
elif ':' == inChar:
charToPut = colon
elif '/' == inChar:
charToPut = forwardSlash
elif ' ' == inChar:
charToPut = space
lineCount = 0
# This loop will iterate each line in the window to display a "line" of the digit
# to be displayed.
for line in charToPut:
# Attributes, or the bitwise combinations of multiple attributes, are passed as-is
# into addstr. Note that not all attributes, or combinations of attributes, will
# work with every terminal.
windowObj.addstr(lineCount, 0, charToPut[lineCount], inAttr)
lineCount = 1 + lineCount
windowObj.refresh()
def main(argv):
# Initialize the curses object.
stdscr = curses.initscr()
# Do not echo keys back to the client.
curses.noecho()
# Non-blocking or cbreak mode... do not wait for Enter key to be pressed.
curses.cbreak()
# Turn off blinking cursor
curses.curs_set(False)
# Enable color if we can...
if curses.has_colors():
curses.start_color()
# Optional - Enable the keypad. This also decodes multi-byte key sequences
# stdscr.keypad(True)
caughtExceptions = ""
try:
# First things first, make sure we have enough room!
if curses.COLS <= 88 or curses.LINES <= 11:
raise Exception ("This terminal window is too small.rn")
currentDT = datetime.datetime.now()
hour = currentDT.strftime("%H")
min = currentDT.strftime("%M")
sec = currentDT.strftime("%S")
# Depending on how the floor values are calculated, an extra character for each
# window may be needed. This code crashed when the windows were set to exactly
# 10x10
topMostY = math.floor((curses.LINES - 11)/2)
leftMostX = math.floor((curses.COLS - 88)/2)
# Note that print statements do not work when using ncurses. If you want to write
# to the terminal outside of a window, use the stdscr.addstr method and specify
# where the text will go. Then use the stdscr.refresh method to refresh the
# display.
stdscr.addstr(curses.LINES-1, 0, "Press a key to quit.")
stdscr.refresh()
# Boxes - Each box must be 1 char bigger than stuff put into it.
hoursLeftWindow = curses.newwin(11, 11, topMostY,leftMostX)
putChar(hoursLeftWindow, hour[0:1])
hoursRightWindow = curses.newwin(11, 11, topMostY,leftMostX+11)
putChar(hoursRightWindow, hour[-1])
leftColonWindow = curses.newwin(11, 11, topMostY,leftMostX+22)
putChar(leftColonWindow, ':', curses.A_BLINK | curses.A_BOLD)
minutesLeftWindow = curses.newwin(11, 11, topMostY, leftMostX+33)
putChar(minutesLeftWindow, min[0:1])
minutesRightWindow = curses.newwin(11, 11, topMostY, leftMostX+44)
putChar(minutesRightWindow, min[-1])
rightColonWindow = curses.newwin(11, 11, topMostY, leftMostX+55)
putChar(rightColonWindow, ':', curses.A_BLINK | curses.A_BOLD)
leftSecondWindow = curses.newwin(11, 11, topMostY, leftMostX+66)
putChar(leftSecondWindow, sec[0:1])
rightSecondWindow = curses.newwin(11, 11, topMostY, leftMostX+77)
putChar(rightSecondWindow, sec[-1])
# One of the boxes must be non-blocking or we can never quit.
hoursLeftWindow.nodelay(True)
while True:
c = hoursLeftWindow.getch()
# In non-blocking mode, the getch method returns -1 except when any key is pressed.
if -1 != c:
break
currentDT = datetime.datetime.now()
currentDTUsec = currentDT.microsecond
# Refreshing the clock "4ish" times a second may be overkill, but doing
# on every single loop iteration shoots active CPU usage up significantly.
# Unfortunately, if we only refresh once a second it is possible to
# skip a second.
# However, this type of restriction breaks functionality in Windows, so
# for that environment, this has to run on Every. Single. Iteration.
if 0 == currentDTUsec % 250000 or sys.platform.startswith("win"):
hour = currentDT.strftime("%H")
min = currentDT.strftime("%M")
sec = currentDT.strftime("%S")
putChar(hoursLeftWindow, hour[0:1], curses.A_BOLD)
putChar(hoursRightWindow, hour[-1], curses.A_BOLD)
putChar(minutesLeftWindow, min[0:1], curses.A_BOLD)
putChar(minutesRightWindow, min[-1], curses.A_BOLD)
putChar(leftSecondWindow, sec[0:1], curses.A_BOLD)
putChar(rightSecondWindow, sec[-1], curses.A_BOLD)
# After breaking out of the loop, we need to clean up the display before quitting.
# The code below blanks out the subwindows.
putChar(hoursLeftWindow, ' ')
putChar(hoursRightWindow, ' ')
putChar(leftColonWindow, ' ')
putChar(minutesLeftWindow, ' ')
putChar(minutesRightWindow, ' ')
putChar(rightColonWindow, ' ')
putChar(leftSecondWindow, ' ')
putChar(rightSecondWindow, ' ')
# De-initialize the window objects.
hoursLeftWindow = None
hoursRightWindow = None
leftColonWindow = None
minutesLeftWindow = None
minutesRightWindow = None
rightColonWindow = None
leftSecondWindow = None
rightSecondWindow = None
except Exception as err:
# Just printing from here will not work, as the program is still set to
# use ncurses.
# print ("Some error [" + str(err) + "] occurred.")
caughtExceptions = str(err)
# End of Program...
# Turn off cbreak mode...
curses.nocbreak()
# Turn echo back on.
curses.echo()
# Restore cursor blinking.
curses.curs_set(True)
# Turn off the keypad...
# stdscr.keypad(False)
# Restore Terminal to original state.
curses.endwin()
# Display Errors if any happened:
if "" != caughtExceptions:
print ("Got error(s) [" + caughtExceptions + "]")
if __name__ == "__main__":
main(sys.argv[1:])
Checking Window Size
Note how the first line within the try
block in the main
function checks the size of the terminal window and raises an exception should it not be sufficiently large enough to display the clock. This is a demonstration of “preemptive” error handling, as if the individual window objects are written to a screen which is too small, a very uninformative exception will be raised.
Cleaning Up Windows With curses
The example above forces a cleanup of the screen for all 3 operating environments. This is done using the putChar(…)
function to print a blank space character to each window object upon breaking out of the while
loop. The objects are then set to None
. Cleaning up window objects in this manner can be a good practice when it is not possible to know all the different terminal configurations that the code could be running on, and having a blank screen on exit gives these kinds of applications a cleaner look overall.
CPU Usage
Like the previous code example, this too works as an “infinite” loop in the sense that it is broken by a condition that is generated by pressing any key. Showing two different ways to break the loop is intentional, as some developers may lean towards one method or another. Note that this code results in extremely high CPU usage because, when run within a loop, Python will consume as much CPU time as it possibly can. Normally, the sleep(…)
function is used to pause execution, but in the case of implementing a clock, this may not be the best way to reduce overall CPU usage. Interestingly enough though, the CPU usage, as reported by the Windows Task Manager for this process is only about 25%, compared to 100% in Linux.
Another interesting observation about CPU usage in Linux: even when simulating significant CPU usage by way of the stress
utility, as per the command below:
$ stress -t 30 -c 16
The demo-clock.py
script was still able to run without losing the proper time.
Going Further With Python curses
This three-part introduction only barely scratches the surface of the Python curses
module, but with this foundation, the task of creating robust user interfaces for text-based Python applications becomes quite doable, even for a novice developer. The only downsides are having to worry about how individual terminal emulation implementations can impact the code, but that will not be that significant of an impediment, and of course, having to deal with the math involved in keeping window objects properly sized and positioned.
The Python curses
module does provide mechanisms for “moving” windows (albeit not very well natively, but this can be mitigated), as well as resizing windows and even compensating for changes in the terminal window size! Even complex text-based games can be (and have been) implemented using the Python curses
module, or its underlying ncurses
C/C++ libraries.
The complete documentation for the ncurses
module can be found in the "curses — Terminal handling for character-cell displays" section of the Python documentation. As the Python curses
module uses syntax that is “close enough” to the underlying ncurses
C/C++ libraries, the manual pages for those libraries, as well as reference resources for those libraries can also be consulted for more information.
Happy “faux” Windowed Programming!
Opinions expressed by DZone contributors are their own.
Comments