Curses spike for Ebookrack Finder

| updated

Objective

To create a skeleton TUI without any business logic using the curses module in the Python standard library.

This is to get a feel for how the library works and whether such a TUI would satisfy the requirements for ebk-find.

App outline

The main loop looks like this:

import curses

def loop(stdscr):
    window = Window(stdscr)

    while True:
        window.handle_next()

        if window.closed:
            break

if __name__ == "__main__":
    try:
        curses.wrapper(loop)
    except KeyboardInterrupt:
        raise SystemExit()

where Window is implemented like this:

class Window:
    def __init__(self, window):
        self.closed = False
        self.win = window

        self.win.addstr(1, 2,  "ebk-find v0.1.0")
        self.win.addstr(20, 2, "Type Ctrl-C to quit")

    def handle_next(self):
        event = self.wait_for_input()

        if event.type is EventType.QUIT:
            self.closed = True
        else:
            assert event.type is EventType.IGNORE, event.type.name

    def wait_for_input(self):
        ch = self.win.getch()
        ...

and events are defined like this:

from collections import namedtuple
from enum import Enum

Event = namedtuple("Event", ["type", "char"])
EventType = Enum("EventType", ["IGNORE", "QUIT"])

For now, we just quit when receiving a control character (Esc, Enter, Ctrl-C, etc.) and ignore all other input:

import curses.ascii

class Window:
    def wait_for_input(self):
        ch = self.win.getch()

        if curses.ascii.iscntrl(ch):
            return Event(EventType.QUIT, None)
        else:
            return Event(EventType.IGNORE, ch)

We add a new SearchBox sub-window to the main window, add a border around it with curses.textpad, and place the cursor inside the search box:

import curses.textpad

class Window:
    def __init__(self, window):
        ...
        self.search_box = SearchBox(self.win, y=4, x=2, height=1, width=30)
        curses.textpad.rectangle(self.win, 3, 1, 5, 32)
        self.win.move(4, 2)

We now emit a CHAR or BACKSPACE event when the user types into the search box:

class Window:
    def wait_for_input(self):
        ch = self.win.getch()

        if curses.ascii.iscntrl(ch):
            return Event(EventType.QUIT, None)
        elif ch in {curses.KEY_BACKSPACE, curses.KEY_DC}:
            return Event(EventType.BACKSPACE, ch)
        elif curses.ascii.isprint(ch):
            return Event(EventType.CHAR, ch)
        else:
            return Event(EventType.IGNORE, ch)

and we handle those events by updating the contents of the search box:

class Window:
    def handle_next(self):
        event = self.wait_for_input()

        if event.type is EventType.QUIT:
            self.closed = True
        elif event.type is EventType.BACKSPACE:
            self.search_box.backspace()
        elif event.type is EventType.CHAR:
            self.search_box.append(event.char)
        else:
            assert event.type is EventType.IGNORE, event.type.name

SearchBox is implemented as follows:

class SearchBox:
    def __init__(self, parent, y, x, height, width):
        self.win = parent.subwin(height, width, y, x)
        self.width = width
        self.contents = ""

    @property
    def cursor_position(self):
        return self.win.getyx()[1]

    def append(self, char):
        self.contents += chr(char)
        if self.cursor_position < self.width - 1:
            self.win.echochar(char)
        self.win.cursyncup()

    def backspace(self):
        self.contents = self.contents[:-1]
        if self.cursor_position > 0:
            self.win.delch(0, self.cursor_position - 1)
            self.win.refresh()
        self.win.cursyncup()

Adding a box for the results

We add a new ResultsBox sub-window to the main window, with a border around it too:

class Window:
    def __init__(self, window):
        ...
        self.results_box = ResultsBox(self.win, y=8, x=2, height=10, width=50)
        curses.textpad.rectangle(self.win, 7, 1, 18, 52)

Now we can update the contents of the results box whenever the contents of the search box changes:

class Window:
    def handle_next(self):
        event = self.wait_for_input()

        if event.type is EventType.QUIT:
            self.closed = True
        elif event.type is EventType.BACKSPACE:
            self.search_box.backspace()
            data = self.filter(DATA)
            self.results_box.write(data)
        elif event.type is EventType.CHAR:
            self.search_box.append(event.char)
            data = self.filter(DATA)
            self.results_box.write(data)
        else:
            assert event.type is EventType.IGNORE, event.type.name

For the purposes of this exercise, let’s just hard-code the data set as a global variable:

DATA = [
    "Antony and Cleopatra",
    "Hamlet",
    "Julius Caesar",
    "King Lear",
    "Macbeth",
    "The Merchant of Venice",
    "A Midsummer Night's Dream",
    "Much Ado About Nothing",
    "Othello",
    "Romeo and Juliet",
    "The Tempest",
    "Titus Andronicus",
]

For the query function, we just use a case-insensitive match:

class Window:
    def filter(self, data):
        query = self.search_box.contents
        return [l for l in data if query.lower() in l.lower()]

ResultsBox is implemented as follows:

class ResultsBox:
    def __init__(self, parent, y, x, height, width):
        self.win = parent.subwin(height, width, y, x)
        self.height = height
        self.width = width

        self.win.addstr(0, 0, "<Start typing a query to display matching titles>")

    def write(self, data):
        self.win.erase()
        for line, text in enumerate(data[:self.height]):
            self.win.addstr(line, 0, text[:self.width])
        self.win.refresh()

Conclusions

Handling user input as single-character integers is fiddly. Notably, this program can only handle input in the ASCII range!

The documentation for the Python module is also pretty terse and seems to assume you know how the C library works.

It also gives no hints as to how to go about testing it.

Still, it wasn’t too hard to figure out, and the program works pretty well. Not bad for ~100 lines of Python!

Appendix

Here is the full script: ebk-find-curses.py