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 ESC[4;38;5;81mTextbox:
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._update_max_yx()
47 self.stripspaces = 1
48 self.lastcmd = None
49 win.keypad(1)
50
51 def _update_max_yx(self):
52 maxy, maxx = self.win.getmaxyx()
53 self.maxy = maxy - 1
54 self.maxx = maxx - 1
55
56 def _end_of_line(self, y):
57 """Go to the location of the first blank on the given line,
58 returning the index of the last non-blank character."""
59 self._update_max_yx()
60 last = self.maxx
61 while True:
62 if curses.ascii.ascii(self.win.inch(y, last)) != curses.ascii.SP:
63 last = min(self.maxx, last+1)
64 break
65 elif last == 0:
66 break
67 last = last - 1
68 return last
69
70 def _insert_printable_char(self, ch):
71 self._update_max_yx()
72 (y, x) = self.win.getyx()
73 backyx = None
74 while y < self.maxy or x < self.maxx:
75 if self.insert_mode:
76 oldch = self.win.inch()
77 # The try-catch ignores the error we trigger from some curses
78 # versions by trying to write into the lowest-rightmost spot
79 # in the window.
80 try:
81 self.win.addch(ch)
82 except curses.error:
83 pass
84 if not self.insert_mode or not curses.ascii.isprint(oldch):
85 break
86 ch = oldch
87 (y, x) = self.win.getyx()
88 # Remember where to put the cursor back since we are in insert_mode
89 if backyx is None:
90 backyx = y, x
91
92 if backyx is not None:
93 self.win.move(*backyx)
94
95 def do_command(self, ch):
96 "Process a single editing command."
97 self._update_max_yx()
98 (y, x) = self.win.getyx()
99 self.lastcmd = ch
100 if curses.ascii.isprint(ch):
101 if y < self.maxy or x < self.maxx:
102 self._insert_printable_char(ch)
103 elif ch == curses.ascii.SOH: # ^a
104 self.win.move(y, 0)
105 elif ch in (curses.ascii.STX,curses.KEY_LEFT,
106 curses.ascii.BS,
107 curses.KEY_BACKSPACE,
108 curses.ascii.DEL):
109 if x > 0:
110 self.win.move(y, x-1)
111 elif y == 0:
112 pass
113 elif self.stripspaces:
114 self.win.move(y-1, self._end_of_line(y-1))
115 else:
116 self.win.move(y-1, self.maxx)
117 if ch in (curses.ascii.BS, curses.KEY_BACKSPACE, curses.ascii.DEL):
118 self.win.delch()
119 elif ch == curses.ascii.EOT: # ^d
120 self.win.delch()
121 elif ch == curses.ascii.ENQ: # ^e
122 if self.stripspaces:
123 self.win.move(y, self._end_of_line(y))
124 else:
125 self.win.move(y, self.maxx)
126 elif ch in (curses.ascii.ACK, curses.KEY_RIGHT): # ^f
127 if x < self.maxx:
128 self.win.move(y, x+1)
129 elif y == self.maxy:
130 pass
131 else:
132 self.win.move(y+1, 0)
133 elif ch == curses.ascii.BEL: # ^g
134 return 0
135 elif ch == curses.ascii.NL: # ^j
136 if self.maxy == 0:
137 return 0
138 elif y < self.maxy:
139 self.win.move(y+1, 0)
140 elif ch == curses.ascii.VT: # ^k
141 if x == 0 and self._end_of_line(y) == 0:
142 self.win.deleteln()
143 else:
144 # first undo the effect of self._end_of_line
145 self.win.move(y, x)
146 self.win.clrtoeol()
147 elif ch == curses.ascii.FF: # ^l
148 self.win.refresh()
149 elif ch in (curses.ascii.SO, curses.KEY_DOWN): # ^n
150 if y < self.maxy:
151 self.win.move(y+1, x)
152 if x > self._end_of_line(y+1):
153 self.win.move(y+1, self._end_of_line(y+1))
154 elif ch == curses.ascii.SI: # ^o
155 self.win.insertln()
156 elif ch in (curses.ascii.DLE, curses.KEY_UP): # ^p
157 if y > 0:
158 self.win.move(y-1, x)
159 if x > self._end_of_line(y-1):
160 self.win.move(y-1, self._end_of_line(y-1))
161 return 1
162
163 def gather(self):
164 "Collect and return the contents of the window."
165 result = ""
166 self._update_max_yx()
167 for y in range(self.maxy+1):
168 self.win.move(y, 0)
169 stop = self._end_of_line(y)
170 if stop == 0 and self.stripspaces:
171 continue
172 for x in range(self.maxx+1):
173 if self.stripspaces and x > stop:
174 break
175 result = result + chr(curses.ascii.ascii(self.win.inch(y, x)))
176 if self.maxy > 0:
177 result = result + "\n"
178 return result
179
180 def edit(self, validate=None):
181 "Edit in the widget window and collect the results."
182 while 1:
183 ch = self.win.getch()
184 if validate:
185 ch = validate(ch)
186 if not ch:
187 continue
188 if not self.do_command(ch):
189 break
190 self.win.refresh()
191 return self.gather()
192
193 if __name__ == '__main__':
194 def test_editbox(stdscr):
195 ncols, nlines = 9, 4
196 uly, ulx = 15, 20
197 stdscr.addstr(uly-2, ulx, "Use Ctrl-G to end editing.")
198 win = curses.newwin(nlines, ncols, uly, ulx)
199 rectangle(stdscr, uly-1, ulx-1, uly + nlines, ulx + ncols)
200 stdscr.refresh()
201 return Textbox(win).edit()
202
203 str = curses.wrapper(test_editbox)
204 print('Contents of text box:', repr(str))