1"""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))
202