We have been editing a lot of files
How can we display a text file in a window?
How can we move around in that file?
How we can change it will be the topic of Chapter 24
import curses
def main(stdscr):
while True:
stdscr.getkey()
if __name__ == "__main__":
curses.wrapper(main)
The curses library handles interaction with a text terminal
curses.wrapper takes a function of a single argument
Does setup, then calls that function with a standard screen
This program just gobbles keystrokes: stop it with Ctrl-C
Printing messages or single-stepping with a debugger is tricky
Create a simple logging function
LOG = None
def open_log(filename):
global LOG
LOG = open(filename, "w")
def log(*args):
print(*args, file=LOG)
def main(stdscr):
while True:
key = stdscr.getkey()
util.log(repr(key))
if key.lower() == "q":
return
if __name__ == "__main__":
util.open_log(sys.argv[1])
curses.wrapper(main)
Print representation of characters to file (so that \n shows up)
Exit cleanly
def main(stdscr, lines):
while True:
stdscr.erase()
for (y, line) in enumerate(lines):
stdscr.addstr(y, 0, line)
key = stdscr.getkey()
if key.lower() == "q":
return
Screen coordinates put (0, 0) in the upper left
And use (y, x) rather than (x, y)
if __name__ == "__main__":
num_lines, logfile = int(sys.argv[1]), sys.argv[2]
lines = make_lines(num_lines)
open_log(logfile)
curses.wrapper(lambda stdscr: main(stdscr, lines))
Make lines for testing rather than showing file
Open the log file
Use lambda to make a one-parameter function for curses.wrapper
from string import ascii_lowercase
def make_lines(num_lines):
result = []
for i in range(num_lines):
ch = ascii_lowercase[i % len(ascii_lowercase)]
result.append(ch + "".join(str(j % 10) for j in range(i)))
return result
a
b0
c01
d012
e0123
Run program with 100 lines (or anything else larger than screen)
_curses.error: addwstr() returned ERR because trying to draw outside screen
So create a Window class that knows how big the screen is
class Window:
def __init__(self, screen):
self._screen = screen
def draw(self, lines):
self._screen.erase()
for (y, line) in enumerate(lines):
if 0 <= y < curses.LINES:
self._screen.addstr(y, 0, line[:curses.COLS])
def main(stdscr, lines):
window = Window(stdscr)
window.draw(lines)
while True:
key = stdscr.getkey()
if key.lower() == "q":
return
Can't create Window before calling curses.wrapper because it needs the screen
Window re-draws everything after every keystroke
Fast enough that people (usually) won't notice
But an obvious target for optimization
Allow users to specify how big the window appears to be
Helps with testing
class Window:
def __init__(self, screen, size):
self._screen = screen
if size is None:
self._nrow = curses.LINES
self._ncol = curses.COLS
else:
self._nrow = min(size[ROW], curses.LINES)
self._ncol = min(size[COL], curses.COLS)
def draw(self, lines):
self._screen.erase()
for (y, line) in enumerate(lines):
if 0 <= y < self._nrow:
self._screen.addstr(y, 0, line[:self._ncol])
Use ROW and COL instead of 0 and 1 (or R and C)
Should really create an enumeration
ROW = 0
COL = 1
class Window:
# ...9 lines not shown...
def draw(self, lines):
self._screen.erase()
for (y, line) in enumerate(lines):
if 0 <= y < self._size[ROW]:
self._screen.addstr(y, 0, line[:self._size[COL]])
class Cursor:
def __init__(self):
self._pos = [0, 0]
def pos(self):
return tuple(self._pos)
def up(self): self._pos[ROW] -= 1
def down(self): self._pos[ROW] += 1
def left(self): self._pos[COL] -= 1
def right(self): self._pos[COL] += 1
Cursor.pos returns location as tuple so caller can't modify itdef main(stdscr, size, lines):
window = Window(stdscr, size)
cursor = Cursor()
while True:
window.draw(lines)
stdscr.move(*cursor.pos())
key = stdscr.getkey()
if key == "KEY_UP": cursor.up()
elif key == "KEY_DOWN": cursor.down()
elif key == "KEY_LEFT": cursor.left()
elif key == "KEY_RIGHT": cursor.right()
elif key.lower() == "q":
return
Spread position into stdscr.move
Screen's getkey method returns names of cursor keys
We can move right or below text
And blow up when moving off left of screen or off top with
_curses.error: wmove() returned ERR
Do some refactoring before fixing this problem
Create an object with a __call__ method
Fool curses.wrapper into thinking we have given it a function
class MainApp:
def __init__(self, size, lines):
self._size = size
self._lines = lines
def __call__(self, screen):
self._setup(screen)
self._run()
def _setup(self, screen):
self._screen = screen
self._window = Window(self._screen, self._size)
self._cursor = Cursor()
def _run(self):
while True:
self._window.draw(self._lines)
self._screen.move(*self._cursor.pos())
key = self._screen.getkey()
if key == "KEY_UP": self._cursor.up()
elif key == "KEY_DOWN": self._cursor.down()
elif key == "KEY_LEFT": self._cursor.left()
elif key == "KEY_RIGHT": self._cursor.right()
elif key.lower() == "q":
return
if __name__ == "__main__":
size, lines = start()
app = MainApp(size, lines)
curses.wrapper(app)
TRANSLATE = {
"\x18": "CONTROL_X"
}
def _interact(self):
key = self._screen.getkey()
key = self.TRANSLATE.get(key, key)
name = f"_do_{key}"
if hasattr(self, name):
getattr(self, name)()
def _do_CONTROL_X(self):
self._running = False
def _do_KEY_UP(self):
self._cursor.up()
class DispatchApp(MainApp):
def __init__(self, size, lines):
super().__init__(size, lines)
self._running = True
def _run(self):
while self._running:
self._window.draw(self._lines)
self._screen.move(*self._cursor.pos())
self._interact()
Use a member variable self._running so that every other method
doesn't have to return a "keep running" flag
Add TRANSLATE to turn things like Ctrl-X into strings CONTROL_X
DispatchApp is a child of MainApp so that we can recycle initialization
DispatchApp.__init__ upcalls to MainApp.__init__
Probably wouldn't create multiple classes in a real program, but simplifies exposition when teaching
Had to move some earlier code around when writing later classes to make everything work cleanly
This is normal
class Buffer:
def __init__(self, lines):
self._lines = lines[:]
def lines(self):
return self._lines
Doesn't do much yet, but will later keep track of viewable region
Makes a copy of lines so that other code can't change its internals
class BufferApp(DispatchApp):
def __init__(self, size, lines):
super().__init__(size, lines)
def _setup(self, screen):
self._screen = screen
self._make_window()
self._make_buffer()
self._make_cursor()
def _make_window(self):
self._window = Window(self._screen, self._size)
def _make_buffer(self):
self._buffer = Buffer(self._lines)
def _make_cursor(self):
self._cursor = Cursor()
Want to re-use as much of BufferApp as possible
If setup calls constructors to create window, buffer, and cursor,
we have to rewrite it each time
Putting constructor calls in factory methods allows us to override them one by one
Could pass classes into BufferApp constructor…
…but later on, these objects will need to reference each other
Again, moving things around like this is normal
class ClipCursor(Cursor):
def __init__(self, buffer):
super().__init__()
self._buffer = buffer
def up(self):
self._pos[ROW] = max(self._pos[ROW]-1, 0)
def down(self):
self._pos[ROW] = min(self._pos[ROW]+1, self._buffer.nrow()-1)
def left(self):
self._pos[COL] = max(self._pos[COL]-1, 0)
def right(self):
self._pos[COL] = min(
self._pos[COL]+1,
self._buffer.ncol(self._pos[ROW])-1
)
class ClipBuffer(Buffer):
def nrow(self):
return len(self._lines)
def ncol(self, row):
return len(self._lines[row])
class ClipApp(BufferApp):
def _make_buffer(self):
self._buffer = ClipBuffer(self._lines)
def _make_cursor(self):
self._cursor = ClipCursor(self._buffer)
Add methods to the buffer
Construct different cursor and buffer objects without changing anything else in the application
class ClipCursorFixed(ClipCursor):
def up(self):
super().up()
self._fix()
def down(self):
super().down()
self._fix()
def _fix(self):
self._pos[COL] = min(
self._pos[COL],
(self._buffer.ncol(self._pos[ROW])-1))
Can still move below the window because cursor is in buffer space not window space
What if the buffer is bigger than the window?
Need a viewport to track the currently-visible portion of the buffer
class ViewportBuffer(ClipBuffer):
def __init__(self, lines):
super().__init__(lines)
self._top = 0
self._height = None
def lines(self):
return self._lines[self._top:self._top + self._height]
def set_height(self, height):
self._height = height
def _bottom(self):
return self._top + self._height
def transform(self, pos):
result = (pos[ROW] - self._top, pos[COL])
return result
def scroll(self, row, col):
if (row == self._top - 1) and self._top > 0:
self._top -= 1
elif (row == self._bottom()) and \
(self._bottom() < self.nrow()):
self._top += 1
class ViewportApp(ClipAppFixed):
def _make_buffer(self):
self._buffer = ViewportBuffer(self._lines)
def _make_cursor(self):
self._cursor = ViewportCursor(self._buffer, self._window)
def _run(self):
self._buffer.set_height(self._window.size()[ROW])
while self._running:
self._window.draw(self._buffer.lines())
screen_pos = self._buffer.transform(self._cursor.pos())
self._screen.move(*screen_pos)
self._interact()
self._buffer.scroll(*self._cursor.pos())
Transform buffer coordinates into screen coordinates
This class is where we need separate factory methods
class ViewportCursor(ClipCursorFixed):
def __init__(self, buffer, window):
super().__init__(buffer)
self._window = window
def left(self):
super().left()
self._fix()
def right(self):
super().right()
self._fix()
def _fix(self):
self._pos[COL] = min(
self._pos[COL],
self._buffer.ncol(self._pos[ROW]) - 1,
self._window.size()[COL] - 1
)