1 | """Simple textbox editing widget with Emacs-like keybindings."""
|
---|
2 |
|
---|
3 | import curses
|
---|
4 | import curses.ascii
|
---|
5 |
|
---|
6 | def rectangle(win, uly, ulx, lry, lrx):
|
---|
7 | """Draw a rectangle with corners at the provided upper-left
|
---|
8 | and lower-right coordinates.
|
---|
9 | """
|
---|
10 | win.vline(uly+1, ulx, curses.ACS_VLINE, lry - uly - 1)
|
---|
11 | win.hline(uly, ulx+1, curses.ACS_HLINE, lrx - ulx - 1)
|
---|
12 | win.hline(lry, ulx+1, curses.ACS_HLINE, lrx - ulx - 1)
|
---|
13 | win.vline(uly+1, lrx, curses.ACS_VLINE, lry - uly - 1)
|
---|
14 | win.addch(uly, ulx, curses.ACS_ULCORNER)
|
---|
15 | win.addch(uly, lrx, curses.ACS_URCORNER)
|
---|
16 | win.addch(lry, lrx, curses.ACS_LRCORNER)
|
---|
17 | win.addch(lry, ulx, curses.ACS_LLCORNER)
|
---|
18 |
|
---|
19 | class Textbox:
|
---|
20 | """Editing widget using the interior of a window object.
|
---|
21 | Supports the following Emacs-like key bindings:
|
---|
22 |
|
---|
23 | Ctrl-A Go to left edge of window.
|
---|
24 | Ctrl-B Cursor left, wrapping to previous line if appropriate.
|
---|
25 | Ctrl-D Delete character under cursor.
|
---|
26 | Ctrl-E Go to right edge (stripspaces off) or end of line (stripspaces on).
|
---|
27 | Ctrl-F Cursor right, wrapping to next line when appropriate.
|
---|
28 | Ctrl-G Terminate, returning the window contents.
|
---|
29 | Ctrl-H Delete character backward.
|
---|
30 | Ctrl-J Terminate if the window is 1 line, otherwise insert newline.
|
---|
31 | Ctrl-K If line is blank, delete it, otherwise clear to end of line.
|
---|
32 | Ctrl-L Refresh screen.
|
---|
33 | Ctrl-N Cursor down; move down one line.
|
---|
34 | Ctrl-O Insert a blank line at cursor location.
|
---|
35 | Ctrl-P Cursor up; move up one line.
|
---|
36 |
|
---|
37 | Move operations do nothing if the cursor is at an edge where the movement
|
---|
38 | is not possible. The following synonyms are supported where possible:
|
---|
39 |
|
---|
40 | KEY_LEFT = Ctrl-B, KEY_RIGHT = Ctrl-F, KEY_UP = Ctrl-P, KEY_DOWN = Ctrl-N
|
---|
41 | KEY_BACKSPACE = Ctrl-h
|
---|
42 | """
|
---|
43 | def __init__(self, win, insert_mode=False):
|
---|
44 | self.win = win
|
---|
45 | self.insert_mode = insert_mode
|
---|
46 | (self.maxy, self.maxx) = win.getmaxyx()
|
---|
47 | self.maxy = self.maxy - 1
|
---|
48 | self.maxx = self.maxx - 1
|
---|
49 | self.stripspaces = 1
|
---|
50 | self.lastcmd = None
|
---|
51 | win.keypad(1)
|
---|
52 |
|
---|
53 | def _end_of_line(self, y):
|
---|
54 | """Go to the location of the first blank on the given line,
|
---|
55 | returning the index of the last non-blank character."""
|
---|
56 | last = self.maxx
|
---|
57 | while True:
|
---|
58 | if curses.ascii.ascii(self.win.inch(y, last)) != curses.ascii.SP:
|
---|
59 | last = min(self.maxx, last+1)
|
---|
60 | break
|
---|
61 | elif last == 0:
|
---|
62 | break
|
---|
63 | last = last - 1
|
---|
64 | return last
|
---|
65 |
|
---|
66 | def _insert_printable_char(self, ch):
|
---|
67 | (y, x) = self.win.getyx()
|
---|
68 | if y < self.maxy or x < self.maxx:
|
---|
69 | if self.insert_mode:
|
---|
70 | oldch = self.win.inch()
|
---|
71 | # The try-catch ignores the error we trigger from some curses
|
---|
72 | # versions by trying to write into the lowest-rightmost spot
|
---|
73 | # in the window.
|
---|
74 | try:
|
---|
75 | self.win.addch(ch)
|
---|
76 | except curses.error:
|
---|
77 | pass
|
---|
78 | if self.insert_mode:
|
---|
79 | (backy, backx) = self.win.getyx()
|
---|
80 | if curses.ascii.isprint(oldch):
|
---|
81 | self._insert_printable_char(oldch)
|
---|
82 | self.win.move(backy, backx)
|
---|
83 |
|
---|
84 | def do_command(self, ch):
|
---|
85 | "Process a single editing command."
|
---|
86 | (y, x) = self.win.getyx()
|
---|
87 | self.lastcmd = ch
|
---|
88 | if curses.ascii.isprint(ch):
|
---|
89 | if y < self.maxy or x < self.maxx:
|
---|
90 | self._insert_printable_char(ch)
|
---|
91 | elif ch == curses.ascii.SOH: # ^a
|
---|
92 | self.win.move(y, 0)
|
---|
93 | elif ch in (curses.ascii.STX,curses.KEY_LEFT, curses.ascii.BS,curses.KEY_BACKSPACE):
|
---|
94 | if x > 0:
|
---|
95 | self.win.move(y, x-1)
|
---|
96 | elif y == 0:
|
---|
97 | pass
|
---|
98 | elif self.stripspaces:
|
---|
99 | self.win.move(y-1, self._end_of_line(y-1))
|
---|
100 | else:
|
---|
101 | self.win.move(y-1, self.maxx)
|
---|
102 | if ch in (curses.ascii.BS, curses.KEY_BACKSPACE):
|
---|
103 | self.win.delch()
|
---|
104 | elif ch == curses.ascii.EOT: # ^d
|
---|
105 | self.win.delch()
|
---|
106 | elif ch == curses.ascii.ENQ: # ^e
|
---|
107 | if self.stripspaces:
|
---|
108 | self.win.move(y, self._end_of_line(y))
|
---|
109 | else:
|
---|
110 | self.win.move(y, self.maxx)
|
---|
111 | elif ch in (curses.ascii.ACK, curses.KEY_RIGHT): # ^f
|
---|
112 | if x < self.maxx:
|
---|
113 | self.win.move(y, x+1)
|
---|
114 | elif y == self.maxy:
|
---|
115 | pass
|
---|
116 | else:
|
---|
117 | self.win.move(y+1, 0)
|
---|
118 | elif ch == curses.ascii.BEL: # ^g
|
---|
119 | return 0
|
---|
120 | elif ch == curses.ascii.NL: # ^j
|
---|
121 | if self.maxy == 0:
|
---|
122 | return 0
|
---|
123 | elif y < self.maxy:
|
---|
124 | self.win.move(y+1, 0)
|
---|
125 | elif ch == curses.ascii.VT: # ^k
|
---|
126 | if x == 0 and self._end_of_line(y) == 0:
|
---|
127 | self.win.deleteln()
|
---|
128 | else:
|
---|
129 | # first undo the effect of self._end_of_line
|
---|
130 | self.win.move(y, x)
|
---|
131 | self.win.clrtoeol()
|
---|
132 | elif ch == curses.ascii.FF: # ^l
|
---|
133 | self.win.refresh()
|
---|
134 | elif ch in (curses.ascii.SO, curses.KEY_DOWN): # ^n
|
---|
135 | if y < self.maxy:
|
---|
136 | self.win.move(y+1, x)
|
---|
137 | if x > self._end_of_line(y+1):
|
---|
138 | self.win.move(y+1, self._end_of_line(y+1))
|
---|
139 | elif ch == curses.ascii.SI: # ^o
|
---|
140 | self.win.insertln()
|
---|
141 | elif ch in (curses.ascii.DLE, curses.KEY_UP): # ^p
|
---|
142 | if y > 0:
|
---|
143 | self.win.move(y-1, x)
|
---|
144 | if x > self._end_of_line(y-1):
|
---|
145 | self.win.move(y-1, self._end_of_line(y-1))
|
---|
146 | return 1
|
---|
147 |
|
---|
148 | def gather(self):
|
---|
149 | "Collect and return the contents of the window."
|
---|
150 | result = ""
|
---|
151 | for y in range(self.maxy+1):
|
---|
152 | self.win.move(y, 0)
|
---|
153 | stop = self._end_of_line(y)
|
---|
154 | if stop == 0 and self.stripspaces:
|
---|
155 | continue
|
---|
156 | for x in range(self.maxx+1):
|
---|
157 | if self.stripspaces and x > stop:
|
---|
158 | break
|
---|
159 | result = result + chr(curses.ascii.ascii(self.win.inch(y, x)))
|
---|
160 | if self.maxy > 0:
|
---|
161 | result = result + "\n"
|
---|
162 | return result
|
---|
163 |
|
---|
164 | def edit(self, validate=None):
|
---|
165 | "Edit in the widget window and collect the results."
|
---|
166 | while 1:
|
---|
167 | ch = self.win.getch()
|
---|
168 | if validate:
|
---|
169 | ch = validate(ch)
|
---|
170 | if not ch:
|
---|
171 | continue
|
---|
172 | if not self.do_command(ch):
|
---|
173 | break
|
---|
174 | self.win.refresh()
|
---|
175 | return self.gather()
|
---|
176 |
|
---|
177 | if __name__ == '__main__':
|
---|
178 | def test_editbox(stdscr):
|
---|
179 | ncols, nlines = 9, 4
|
---|
180 | uly, ulx = 15, 20
|
---|
181 | stdscr.addstr(uly-2, ulx, "Use Ctrl-G to end editing.")
|
---|
182 | win = curses.newwin(nlines, ncols, uly, ulx)
|
---|
183 | rectangle(stdscr, uly-1, ulx-1, uly + nlines, ulx + ncols)
|
---|
184 | stdscr.refresh()
|
---|
185 | return Textbox(win).edit()
|
---|
186 |
|
---|
187 | str = curses.wrapper(test_editbox)
|
---|
188 | print 'Contents of text box:', repr(str)
|
---|