LanguagesPythonPython curses: Working with Windowed Content

Python curses: Working with Windowed Content

Python tutorials

Welcome 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:

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.\r\n")
    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 at curses — Terminal handling for character-cell displays — Python 3.10.5 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!

Read more Python programming tutorials and software development tips.

Latest Posts

Related Stories