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)
Adding a search box
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