• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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,
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
193if __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))
205