Advertisement
Guest User

__main__.py

a guest
Aug 23rd, 2017
68
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 14.11 KB | None | 0 0
  1. """Simple curses-based text editor."""
  2.  
  3. from contextlib import contextmanager
  4. from sys import argv
  5. import curses
  6. import os
  7.  
  8.  
  9. class Buffer(object):
  10. """The basic data structure for editable text.
  11. The buffer is column and row oriented. Column and row numbers start with 0.
  12. A buffer always has at least one row. All positions within a buffer specify
  13. a position between characters.
  14. """
  15.  
  16. def __init__(self, text=''):
  17. """Create a new Buffer, optionally initialized with text."""
  18. self._lines = text.split('\n')
  19.  
  20. def get_lines(self):
  21. """Return list of lines in the buffer."""
  22. return list(self._lines) # return a copy
  23.  
  24. def _check_point(self, row, col):
  25. """Raise ValueError if the given row and col are not a valid point."""
  26. if row < 0 or row > len(self._lines) - 1:
  27. raise ValueError("Invalid row: '{}'".format(row))
  28. cur_row = self._lines[row]
  29. if col < 0 or col > len(cur_row):
  30. raise ValueError("Invalid col: '{}'".format(col))
  31.  
  32. def set_text(self, row1, col1, row2, col2, text):
  33. """Set the text in the given range.
  34. The end of the range is exclusive (to allow inserting text without
  35. removing a single character). Column numbers are positions between
  36. characters.
  37. Raises ValueError if the range is invalid.
  38. """
  39. # TODO check that point2 is after or the same as point1
  40. self._check_point(row1, col1)
  41. self._check_point(row2, col2)
  42.  
  43. line = self._lines[row1][:col1] + text + self._lines[row2][col2:]
  44. self._lines[row1:row2+1] = line.split('\n')
  45.  
  46.  
  47. class EditorGUI(object):
  48.  
  49. def __init__(self, stdscr, filename):
  50. """Create the GUI with curses screen and optional filename to load."""
  51. self._stdscr = stdscr
  52.  
  53. # if filename already exists, try to load from it
  54. text = ''
  55. if filename != None and os.path.isfile(filename):
  56. with open(filename) as f:
  57. text = f.read()
  58.  
  59. self._filename = filename
  60. self._buf = Buffer(text)
  61. self._row = 0
  62. self._col = 0
  63. self._scroll_top = 0 # the first line number in the window
  64. self._mode = 'normal'
  65. self._message = ''
  66. self._will_exit = False
  67.  
  68. def _draw_gutter(self, num_start, num_rows, last_line_num):
  69. """Draw the gutter, and return the gutter width."""
  70. line_nums = range(num_start, num_start + num_rows)
  71. assert len(line_nums) == num_rows
  72. gutter_width = max(3, len(str(last_line_num))) + 1
  73. for y, line_num in enumerate(line_nums):
  74. if line_num > last_line_num:
  75. text = '~'.ljust(gutter_width)
  76. else:
  77. text = '{} '.format(line_num).rjust(gutter_width)
  78. self._stdscr.addstr(y, 0, text, curses.A_REVERSE)
  79. return gutter_width
  80.  
  81. def _draw(self):
  82. """Draw the GUI."""
  83. self._stdscr.erase()
  84. height = self._stdscr.getmaxyx()[0]
  85. width = self._stdscr.getmaxyx()[1]
  86. self._draw_status_line(0, height - 1, width)
  87. self._draw_text(0, 0, width, height - 1)
  88. self._stdscr.refresh()
  89.  
  90. def _draw_status_line(self, left, top, width):
  91. """Draw the status line."""
  92. # TODO: can't write to bottom right cell
  93. mode = '{} {}'.format(self._mode.upper(),
  94. self._message).ljust(width - 1)
  95. self._stdscr.addstr(top, left, mode, curses.A_REVERSE)
  96. position = 'LN {}:{} '.format(self._row + 1, self._col + 1)
  97. self._stdscr.addstr(top, left + width - 1 - len(position), position,
  98. curses.A_REVERSE)
  99.  
  100. def _get_num_wrapped_lines(self, line_num, width):
  101. """Return the number of lines the given line number wraps to."""
  102. return len(self._get_wrapped_lines(line_num, width))
  103.  
  104. def _get_wrapped_lines(self, line_num, width, convert_nonprinting=True):
  105. """Return the wrapped lines for the given line number."""
  106. def wrap_text(text, width):
  107. """Wrap string text into list of strings."""
  108. if text == '':
  109. yield ''
  110. else:
  111. for i in xrange(0, len(text), width):
  112. yield text[i:i + width]
  113. assert line_num >= 0, 'line_num must be > 0'
  114. line = self._buf.get_lines()[line_num]
  115. if convert_nonprinting:
  116. line = self._convert_nonprinting(line)
  117. return list(wrap_text(line, width))
  118.  
  119. def _scroll_bottom_to_top(self, bottom, width, height):
  120. """Return the first visible line's number so bottom line is visible."""
  121. def verify(top):
  122. """Verify the result of the parent function is correct."""
  123. rows = [list(self._get_wrapped_lines(n, width))
  124. for n in range(top, bottom + 1)]
  125. num_rows = sum(len(r) for r in rows)
  126. assert top <= bottom, ('top line {} may not be below bottom {}'
  127. .format(top, bottom))
  128. assert num_rows <= height, (
  129. '{} rows between {} and {}, but only {} remaining. rows are {}'
  130. .format(num_rows, top, bottom, height, rows))
  131.  
  132. top, next_top = bottom, bottom
  133. # distance in number of lines between top and bottom
  134. distance = self._get_num_wrapped_lines(bottom, width)
  135.  
  136. # move top upwards as far as possible
  137. while next_top >= 0 and distance <= height:
  138. top = next_top
  139. next_top -= 1
  140. distance += self._get_num_wrapped_lines(max(0, next_top), width)
  141.  
  142. verify(top)
  143. return top
  144.  
  145. def _scroll_to(self, line_num, width, row_height):
  146. """Scroll so the line with the given number is visible."""
  147. # lowest scroll top that would still keep line_num visible
  148. lowest_top = self._scroll_bottom_to_top(line_num, width, row_height)
  149.  
  150. if line_num < self._scroll_top:
  151. # scroll up until line_num is visible
  152. self._scroll_top = line_num
  153. elif self._scroll_top < lowest_top:
  154. # scroll down to until line_num is visible
  155. self._scroll_top = lowest_top
  156.  
  157. @staticmethod
  158. def _convert_nonprinting(text):
  159. """Replace nonprinting character in text."""
  160. # TODO: it would be nice if these could be highlighted when displayed
  161. res = []
  162. for char in text:
  163. i = ord(char)
  164. if char == '\t':
  165. res.append('-> ')
  166. elif i < 32 or i > 126:
  167. res.append('<{}>'.format(hex(i)[2:]))
  168. else:
  169. res.append(char)
  170. return ''.join(res)
  171.  
  172. def _draw_text(self, left, top, width, height):
  173. """Draw the text area."""
  174. # TODO: handle single lines that occupy the entire window
  175. highest_line_num = len(self._buf.get_lines())
  176. gutter_width = max(3, len(str(highest_line_num))) + 1
  177. line_width = width - gutter_width # width to which text is wrapped
  178. cursor_y, cursor_x = None, None # where the cursor will be drawn
  179.  
  180. # set scroll_top so the cursor is visible
  181. self._scroll_to(self._row, line_width, height)
  182.  
  183. line_nums = range(self._scroll_top, highest_line_num)
  184. cur_y = top
  185. trailing_char = '~'
  186.  
  187. for line_num in line_nums:
  188.  
  189. # if there are no more rows left, break
  190. num_remaining_rows = top + height - cur_y
  191. if num_remaining_rows == 0:
  192. break
  193.  
  194. # if all the wrapped lines can't fit on screen, break
  195. wrapped_lines = self._get_wrapped_lines(line_num, line_width)
  196. if len(wrapped_lines) > num_remaining_rows:
  197. trailing_char = '@'
  198. break
  199.  
  200. # calculate cursor position if cursor must be on this line
  201. if line_num == self._row:
  202. lines = self._get_wrapped_lines(line_num, line_width,
  203. convert_nonprinting=False)
  204. real_col = len(self._convert_nonprinting(
  205. ''.join(lines)[:self._col])
  206. )
  207. cursor_y = cur_y + real_col / line_width
  208. cursor_x = left + gutter_width + real_col % line_width
  209.  
  210. # draw all the wrapped lines
  211. for n, wrapped_line in enumerate(wrapped_lines):
  212. if n == 0:
  213. gutter = '{} '.format(line_num + 1).rjust(gutter_width)
  214. else:
  215. gutter = ' ' * gutter_width
  216. self._stdscr.addstr(cur_y, left, gutter, curses.A_REVERSE)
  217. self._stdscr.addstr(cur_y, left + len(gutter), wrapped_line)
  218. cur_y += 1
  219.  
  220. # draw empty lines
  221. for cur_y in range(cur_y, top + height):
  222. gutter = trailing_char.ljust(gutter_width)
  223. self._stdscr.addstr(cur_y, left, gutter)
  224.  
  225. # position the cursor
  226. assert cursor_x != None and cursor_y != None
  227. self._stdscr.move(cursor_y + 0, cursor_x + 0)
  228.  
  229. def _handle_normal_keypress(self, char):
  230. """Handle a keypress in normal mode."""
  231. if char == ord('q'): # quit
  232. self._will_exit = True
  233. elif char == ord('j'): # down
  234. self._row += 1
  235. elif char == ord('k'): # up
  236. self._row -= 1
  237. elif char == ord('h'): # left
  238. self._col -= 1
  239. elif char == ord('l'): # right
  240. self._col += 1
  241. elif char == ord('0'): # move to beginning of line
  242. self._col = 0
  243. elif char == ord('$'): # move to end of line
  244. cur_line_len = len(self._buf.get_lines()[self._row])
  245. self._col = cur_line_len - 1
  246. elif char == ord('x'): # delete a character
  247. self._buf.set_text(self._row, self._col, self._row,
  248. self._col + 1, '')
  249. elif char == ord('i'): # enter insert mode
  250. self._mode = "insert"
  251. elif char == ord('a'): # enter insert mode after cursor
  252. self._mode = "insert"
  253. self._col += 1
  254. elif char == ord('o'): # insert line after current
  255. cur_line_len = len(self._buf.get_lines()[self._row])
  256. self._buf.set_text(self._row, cur_line_len, self._row,
  257. cur_line_len, '\n')
  258. self._row += 1
  259. self._col = 0
  260. self._mode = "insert"
  261. elif char == ord('O'): # insert line before current
  262. self._buf.set_text(self._row, 0, self._row, 0, '\n')
  263. self._col = 0
  264. self._mode = "insert"
  265. elif char == ord('w'): # write file
  266. if self._filename == None:
  267. self._message = 'Can\'t write file without filename.'
  268. else:
  269. try:
  270. with open(self._filename, 'w') as f:
  271. f.write('\n'.join(self._buf.get_lines()))
  272. except IOError as e:
  273. self._message = ('Failed to write file \'{}\': {}'
  274. .format(self._filename, e))
  275. else:
  276. self._message = 'Unknown key: {}'.format(char)
  277.  
  278. def _handle_insert_keypress(self, char):
  279. """Handle a keypress in insert mode."""
  280. if char == 27:
  281. # leaving insert mode moves cursor left
  282. if self._mode == 'insert':
  283. self._col -= 1
  284. self._mode = "normal"
  285. elif char == 127: # backspace
  286. if self._col == 0 and self._row == 0:
  287. pass # no effect
  288. elif self._col == 0:
  289. # join the current line with the previous one
  290. prev_line = self._buf.get_lines()[self._row - 1]
  291. cur_line = self._buf.get_lines()[self._row]
  292. self._buf.set_text(self._row - 1, 0, self._row,
  293. len(cur_line), prev_line + cur_line)
  294. self._col = len(prev_line)
  295. self._row -= 1
  296. else:
  297. # remove the previous character
  298. self._buf.set_text(self._row, self._col - 1, self._row,
  299. self._col, '')
  300. self._col -= 1
  301. else:
  302. self._message = ('inserted {} at row {} col {}'
  303. .format(char, self._row, self._col))
  304. self._buf.set_text(self._row, self._col, self._row,
  305. self._col, chr(char))
  306. if chr(char) == '\n':
  307. self._row += 1
  308. self._col = 0
  309. else:
  310. self._col += 1
  311.  
  312. def main(self):
  313. """GUI main loop."""
  314. while not self._will_exit:
  315. self._draw()
  316. self._message = ''
  317.  
  318. char = self._stdscr.getch()
  319. if self._mode == 'normal':
  320. self._handle_normal_keypress(char)
  321. elif self._mode == 'insert':
  322. self._handle_insert_keypress(char)
  323.  
  324. # TODO: get rid of this position clipping
  325. num_lines = len(self._buf.get_lines())
  326. self._row = min(num_lines - 1, max(0, self._row))
  327. # on empty lines, still allow col 1
  328. num_cols = max(1, len(self._buf.get_lines()[self._row]))
  329. # in insert mode, allow using append after the last char
  330. if self._mode == 'insert':
  331. num_cols += 1
  332. self._col = min(num_cols - 1, max(0, self._col))
  333.  
  334.  
  335. @contextmanager
  336. def use_curses():
  337. """Context manager to set up and tear down curses."""
  338. stdscr = curses.initscr()
  339. curses.noecho() # do not echo keys
  340. curses.cbreak() # don't wait for enter
  341. try:
  342. yield stdscr
  343. finally:
  344. # clean up and exit
  345. curses.nocbreak()
  346. stdscr.keypad(0)
  347. curses.echo()
  348. curses.endwin()
  349.  
  350.  
  351. def curses_main():
  352. """Start the curses GUI."""
  353. filename = argv[1] if len(argv) > 1 else None
  354. with use_curses() as stdscr:
  355. gui = EditorGUI(stdscr, filename)
  356. gui.main()
  357.  
  358.  
  359. if __name__ == '__main__':
  360. curses_main()
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement