blob: 2079953a06614ba7ffc8f669ed9849f9b4d2ceb4 [file] [log] [blame]
Olivier Deprezf4ef2d02021-04-20 13:36:24 +02001"""Simple textbox editing widget with Emacs-like keybindings."""
2
3import curses
4import curses.ascii
5
6def 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
19class 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._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, curses.ascii.BS,curses.KEY_BACKSPACE):
106 if x > 0:
107 self.win.move(y, x-1)
108 elif y == 0:
109 pass
110 elif self.stripspaces:
111 self.win.move(y-1, self._end_of_line(y-1))
112 else:
113 self.win.move(y-1, self.maxx)
114 if ch in (curses.ascii.BS, curses.KEY_BACKSPACE):
115 self.win.delch()
116 elif ch == curses.ascii.EOT: # ^d
117 self.win.delch()
118 elif ch == curses.ascii.ENQ: # ^e
119 if self.stripspaces:
120 self.win.move(y, self._end_of_line(y))
121 else:
122 self.win.move(y, self.maxx)
123 elif ch in (curses.ascii.ACK, curses.KEY_RIGHT): # ^f
124 if x < self.maxx:
125 self.win.move(y, x+1)
126 elif y == self.maxy:
127 pass
128 else:
129 self.win.move(y+1, 0)
130 elif ch == curses.ascii.BEL: # ^g
131 return 0
132 elif ch == curses.ascii.NL: # ^j
133 if self.maxy == 0:
134 return 0
135 elif y < self.maxy:
136 self.win.move(y+1, 0)
137 elif ch == curses.ascii.VT: # ^k
138 if x == 0 and self._end_of_line(y) == 0:
139 self.win.deleteln()
140 else:
141 # first undo the effect of self._end_of_line
142 self.win.move(y, x)
143 self.win.clrtoeol()
144 elif ch == curses.ascii.FF: # ^l
145 self.win.refresh()
146 elif ch in (curses.ascii.SO, curses.KEY_DOWN): # ^n
147 if y < self.maxy:
148 self.win.move(y+1, x)
149 if x > self._end_of_line(y+1):
150 self.win.move(y+1, self._end_of_line(y+1))
151 elif ch == curses.ascii.SI: # ^o
152 self.win.insertln()
153 elif ch in (curses.ascii.DLE, curses.KEY_UP): # ^p
154 if y > 0:
155 self.win.move(y-1, x)
156 if x > self._end_of_line(y-1):
157 self.win.move(y-1, self._end_of_line(y-1))
158 return 1
159
160 def gather(self):
161 "Collect and return the contents of the window."
162 result = ""
163 self._update_max_yx()
164 for y in range(self.maxy+1):
165 self.win.move(y, 0)
166 stop = self._end_of_line(y)
167 if stop == 0 and self.stripspaces:
168 continue
169 for x in range(self.maxx+1):
170 if self.stripspaces and x > stop:
171 break
172 result = result + chr(curses.ascii.ascii(self.win.inch(y, x)))
173 if self.maxy > 0:
174 result = result + "\n"
175 return result
176
177 def edit(self, validate=None):
178 "Edit in the widget window and collect the results."
179 while 1:
180 ch = self.win.getch()
181 if validate:
182 ch = validate(ch)
183 if not ch:
184 continue
185 if not self.do_command(ch):
186 break
187 self.win.refresh()
188 return self.gather()
189
190if __name__ == '__main__':
191 def test_editbox(stdscr):
192 ncols, nlines = 9, 4
193 uly, ulx = 15, 20
194 stdscr.addstr(uly-2, ulx, "Use Ctrl-G to end editing.")
195 win = curses.newwin(nlines, ncols, uly, ulx)
196 rectangle(stdscr, uly-1, ulx-1, uly + nlines, ulx + ncols)
197 stdscr.refresh()
198 return Textbox(win).edit()
199
200 str = curses.wrapper(test_editbox)
201 print('Contents of text box:', repr(str))