textpad.py 7.6 KB

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