textpad.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  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, curses.ascii.BS,curses.KEY_BACKSPACE):
  95. if x > 0:
  96. self.win.move(y, x-1)
  97. elif y == 0:
  98. pass
  99. elif self.stripspaces:
  100. self.win.move(y-1, self._end_of_line(y-1))
  101. else:
  102. self.win.move(y-1, self.maxx)
  103. if ch in (curses.ascii.BS, curses.KEY_BACKSPACE):
  104. self.win.delch()
  105. elif ch == curses.ascii.EOT: # ^d
  106. self.win.delch()
  107. elif ch == curses.ascii.ENQ: # ^e
  108. if self.stripspaces:
  109. self.win.move(y, self._end_of_line(y))
  110. else:
  111. self.win.move(y, self.maxx)
  112. elif ch in (curses.ascii.ACK, curses.KEY_RIGHT): # ^f
  113. if x < self.maxx:
  114. self.win.move(y, x+1)
  115. elif y == self.maxy:
  116. pass
  117. else:
  118. self.win.move(y+1, 0)
  119. elif ch == curses.ascii.BEL: # ^g
  120. return 0
  121. elif ch == curses.ascii.NL: # ^j
  122. if self.maxy == 0:
  123. return 0
  124. elif y < self.maxy:
  125. self.win.move(y+1, 0)
  126. elif ch == curses.ascii.VT: # ^k
  127. if x == 0 and self._end_of_line(y) == 0:
  128. self.win.deleteln()
  129. else:
  130. # first undo the effect of self._end_of_line
  131. self.win.move(y, x)
  132. self.win.clrtoeol()
  133. elif ch == curses.ascii.FF: # ^l
  134. self.win.refresh()
  135. elif ch in (curses.ascii.SO, curses.KEY_DOWN): # ^n
  136. if y < self.maxy:
  137. self.win.move(y+1, x)
  138. if x > self._end_of_line(y+1):
  139. self.win.move(y+1, self._end_of_line(y+1))
  140. elif ch == curses.ascii.SI: # ^o
  141. self.win.insertln()
  142. elif ch in (curses.ascii.DLE, curses.KEY_UP): # ^p
  143. if y > 0:
  144. self.win.move(y-1, x)
  145. if x > self._end_of_line(y-1):
  146. self.win.move(y-1, self._end_of_line(y-1))
  147. return 1
  148. def gather(self):
  149. "Collect and return the contents of the window."
  150. result = ""
  151. self._update_max_yx()
  152. for y in range(self.maxy+1):
  153. self.win.move(y, 0)
  154. stop = self._end_of_line(y)
  155. if stop == 0 and self.stripspaces:
  156. continue
  157. for x in range(self.maxx+1):
  158. if self.stripspaces and x > stop:
  159. break
  160. result = result + chr(curses.ascii.ascii(self.win.inch(y, x)))
  161. if self.maxy > 0:
  162. result = result + "\n"
  163. return result
  164. def edit(self, validate=None):
  165. "Edit in the widget window and collect the results."
  166. while 1:
  167. ch = self.win.getch()
  168. if validate:
  169. ch = validate(ch)
  170. if not ch:
  171. continue
  172. if not self.do_command(ch):
  173. break
  174. self.win.refresh()
  175. return self.gather()
  176. if __name__ == '__main__':
  177. def test_editbox(stdscr):
  178. ncols, nlines = 9, 4
  179. uly, ulx = 15, 20
  180. stdscr.addstr(uly-2, ulx, "Use Ctrl-G to end editing.")
  181. win = curses.newwin(nlines, ncols, uly, ulx)
  182. rectangle(stdscr, uly-1, ulx-1, uly + nlines, ulx + ncols)
  183. stdscr.refresh()
  184. return Textbox(win).edit()
  185. str = curses.wrapper(test_editbox)
  186. print('Contents of text box:', repr(str))