!/usr/bin/env python3 “”“Simple curses-based text editor.
This code is based on github.com/tdryer/editor by tdryer. The original code is licensed under MIT:
The MIT License (MIT)
Copyright © 2014 Tom Dryer
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. “”“ import time import argparse from contextlib import contextmanager import whatthepatch import sys import curses import os CHAR_ESC = 27 CHAR_BKSP = 127
class Buffer(object):
"""The basic data structure for editable text.
The buffer is column and row oriented. Column and row numbers start with 0.
A buffer always has at least one row. All positions within a buffer specify
a position between characters.
"""
def __init__(self, text=''):
"""Create a new Buffer, optionally initialized with text."""
self._lines = text.split('\n')
def get_lines(self):
"""Return list of lines in the buffer."""
return list(self._lines) # return a copy
def _check_point(self, row, col):
"""Raise ValueError if the given row and col are not a valid point."""
if row < 0 or row > len(self._lines) - 1:
raise ValueError("Invalid row: '{}'".format(row))
cur_row = self._lines[row]
if col < 0 or col > len(cur_row):
raise ValueError("Invalid col: '{}'".format(col))
def set_text(self, row1, col1, row2, col2, text):
"""Set the text in the given range.
The end of the range is exclusive (to allow inserting text without
removing a single character). Column numbers are positions between
characters.
Raises ValueError if the range is invalid.
"""
# TODO check that point2 is after or the same as point1
self._check_point(row1, col1)
self._check_point(row2, col2)
line = self._lines[row1][:col1] + text + self._lines[row2][col2:]
self._lines[row1:row2+1] = line.split('\n')
class EditorGUI(object):
def __init__(self, stdscr, filename, stream=None, speed=0, nosave=False, seshlen=0):
"""Create the GUI with curses screen and optional filename to load."""
self._stdscr = stdscr
# if filename already exists, try to load from it
text = ''
if filename != None and os.path.isfile(filename):
with open(filename) as f:
text = f.read()
self._filename = filename
self._buf = Buffer(text)
self._row = 0
self._col = 0
self._scroll_top = 0 # the first line number in the window
self._mode = 'normal'
self._message = ''
self._will_exit = False
# Extras
self._char_stream = stream
self._speed = speed
self._nosave = nosave
self._seshlen = seshlen
self._start_time = time.time()
def _draw_gutter(self, num_start, num_rows, last_line_num):
"""Draw the gutter, and return the gutter width."""
line_nums = list(range(num_start, num_start + num_rows))
assert len(line_nums) == num_rows
gutter_width = max(3, len(str(last_line_num))) + 1
for y, line_num in enumerate(line_nums):
if line_num > last_line_num:
text = '~'.ljust(gutter_width)
else:
text = '{} '.format(line_num).rjust(gutter_width)
self._stdscr.addstr(y, 0, text, curses.A_REVERSE)
return gutter_width
def _draw(self):
"""Draw the GUI."""
self._stdscr.erase()
height = self._stdscr.getmaxyx()[0]
width = self._stdscr.getmaxyx()[1]
# self._draw_status_line(0, height - 1, width)
self._draw_text(0, 0, width, height - 1)
self._stdscr.refresh()
def _draw_status_line(self, left, top, width):
"""Draw the status line."""
# TODO: can't write to bottom right cell
mode = '{} {} {}'.format(self._filename, self._mode.upper(),
self._message).ljust(width - 1)
self._stdscr.addstr(top, left, mode, curses.A_REVERSE)
position = 'LN {}:{} '.format(self._row + 1, self._col + 1)
self._stdscr.addstr(top, left + width - 1 - len(position), position,
curses.A_REVERSE)
def _get_num_wrapped_lines(self, line_num, width):
"""Return the number of lines the given line number wraps to."""
return len(self._get_wrapped_lines(line_num, width))
def _get_wrapped_lines(self, line_num, width, convert_nonprinting=True):
"""Return the wrapped lines for the given line number."""
def wrap_text(text, width):
"""Wrap string text into list of strings."""
if text == '':
yield ''
else:
for i in range(0, len(text), width):
yield text[i:i + width]
assert line_num >= 0, 'line_num must be > 0'
line = self._buf.get_lines()[line_num]
if convert_nonprinting:
line = self._convert_nonprinting(line)
return list(wrap_text(line, width))
def _scroll_bottom_to_top(self, bottom, width, height):
"""Return the first visible line's number so bottom line is visible."""
def verify(top):
"""Verify the result of the parent function is correct."""
rows = [list(self._get_wrapped_lines(n, width))
for n in range(top, bottom + 1)]
num_rows = sum(len(r) for r in rows)
assert top <= bottom, ('top line {} may not be below bottom {}'
.format(top, bottom))
assert num_rows <= height, (
'{} rows between {} and {}, but only {} remaining. rows are {}'
.format(num_rows, top, bottom, height, rows))
top, next_top = bottom, bottom
# distance in number of lines between top and bottom
distance = self._get_num_wrapped_lines(bottom, width)
# move top upwards as far as possible
while next_top >= 0 and distance <= height:
top = next_top
next_top -= 1
distance += self._get_num_wrapped_lines(max(0, next_top), width)
verify(top)
return top
def _scroll_to(self, line_num, width, row_height):
"""Scroll so the line with the given number is visible."""
# lowest scroll top that would still keep line_num visible
lowest_top = self._scroll_bottom_to_top(line_num, width, row_height)
if line_num < self._scroll_top:
# scroll up until line_num is visible
self._scroll_top = line_num
elif self._scroll_top < lowest_top:
# scroll down to until line_num is visible
self._scroll_top = lowest_top
@staticmethod
def _convert_nonprinting(text):
"""Replace nonprinting character in text."""
# TODO: it would be nice if these could be highlighted when displayed
res = []
for char in text:
i = ord(char)
if char == '\t':
res.append('-> ')
elif i < 32 or i > 126:
res.append('<{}>'.format(hex(i)[2:]))
else:
res.append(char)
return ''.join(res)
def _draw_text(self, left, top, width, height):
"""Draw the text area."""
# TODO: handle single lines that occupy the entire window
highest_line_num = len(self._buf.get_lines())
gutter_width = max(3, len(str(highest_line_num))) + 1
line_width = width - gutter_width # width to which text is wrapped
cursor_y, cursor_x = None, None # where the cursor will be drawn
# set scroll_top so the cursor is visible
self._scroll_to(self._row, line_width, height)
line_nums = list(range(self._scroll_top, highest_line_num))
cur_y = top
trailing_char = '~'
for line_num in line_nums:
# if there are no more rows left, break
num_remaining_rows = top + height - cur_y
if num_remaining_rows == 0:
break
# if all the wrapped lines can't fit on screen, break
wrapped_lines = self._get_wrapped_lines(line_num, line_width)
if len(wrapped_lines) > num_remaining_rows:
trailing_char = '@'
break
# calculate cursor position if cursor must be on this line
if line_num == self._row:
lines = self._get_wrapped_lines(line_num, line_width,
convert_nonprinting=False)
real_col = len(self._convert_nonprinting(
''.join(lines)[:self._col])
)
cursor_y = cur_y + real_col / line_width
cursor_x = left + gutter_width + real_col % line_width
# draw all the wrapped lines
for n, wrapped_line in enumerate(wrapped_lines):
if n == 0:
gutter = '{} '.format(line_num + 1).rjust(gutter_width)
else:
gutter = ' ' * gutter_width
self._stdscr.addstr(cur_y, left, gutter, curses.A_REVERSE)
self._stdscr.addstr(cur_y, left + len(gutter), wrapped_line)
cur_y += 1
# draw empty lines
for cur_y in range(cur_y, top + height):
gutter = trailing_char.ljust(gutter_width)
self._stdscr.addstr(cur_y, left, gutter)
# position the cursor
assert cursor_x != None and cursor_y != None
self._stdscr.move(int(cursor_y) + 0, int(cursor_x) + 0)
def _handle_normal_keypress(self, char):
"""Handle a keypress in normal mode."""
if char == ord('q'): # quit
self._will_exit = True
elif char == ord('j'): # down
self._row += 1
elif char == ord('k'): # up
self._row -= 1
elif char == ord('h'): # left
self._col -= 1
elif char == ord('l'): # right
self._col += 1
elif char == ord('0'): # move to beginning of line
self._col = 0
elif char == ord('$'): # move to end of line
cur_line_len = len(self._buf.get_lines()[self._row])
self._col = cur_line_len - 1
elif char == ord('x'): # delete a character
self._buf.set_text(self._row, self._col, self._row,
self._col + 1, '')
elif char == ord('i'): # enter insert mode
self._mode = "insert"
elif char == ord('a'): # enter insert mode after cursor
self._mode = "insert"
self._col += 1
elif char == ord('o'): # insert line after current
cur_line_len = len(self._buf.get_lines()[self._row])
self._buf.set_text(self._row, cur_line_len, self._row,
cur_line_len, '\n')
self._row += 1
self._col = 0
self._mode = "insert"
elif char == ord('O'): # insert line before current
self._buf.set_text(self._row, 0, self._row, 0, '\n')
self._col = 0
self._mode = "insert"
elif char == ord('w'): # write file
if self._filename == None:
self._message = 'Can\'t write file without filename.'
else:
try:
with open(self._filename, 'w') as f:
f.write('\n'.join(self._buf.get_lines()))
except IOError as e:
self._message = ('Failed to write file \'{}\': {}'
.format(self._filename, e))
else:
self._message = 'Unknown key: {}'.format(char)
def _handle_insert_keypress(self, char):
"""Handle a keypress in insert mode."""
if char == CHAR_ESC:
# leaving insert mode moves cursor left
if self._mode == 'insert':
self._col -= 1
self._mode = "normal"
elif char == CHAR_BKSP: # backspace
if self._col == 0 and self._row == 0:
pass # no effect
elif self._col == 0:
# join the current line with the previous one
prev_line = self._buf.get_lines()[self._row - 1]
cur_line = self._buf.get_lines()[self._row]
self._buf.set_text(self._row - 1, 0, self._row,
len(cur_line), prev_line + cur_line)
self._col = len(prev_line)
self._row -= 1
else:
# remove the previous character
self._buf.set_text(self._row, self._col - 1, self._row,
self._col, '')
self._col -= 1
else:
self._message = ('inserted {} at row {} col {}'
.format(char, self._row, self._col))
self._buf.set_text(self._row, self._col, self._row,
self._col, chr(char))
if chr(char) == '\n':
self._row += 1
self._col = 0
else:
self._col += 1
def main(self):
"""GUI main loop."""
# Reverse to permit popping
if self._char_stream is not None:
stream = self._char_stream[::-1]
automated = True
else:
automated = False
while not self._will_exit:
self._draw()
self._message = ''
time.sleep(self._speed)
if automated:
if len(stream) > 0:
if len(stream) == len(self._char_stream):
# Pause before starting
time.sleep(2)
char = stream.pop()
else:
# Push write and quit
stream.append(ord('q'))
if not self._nosave:
stream.append(ord('w'))
# Pause before writing/exiting
now = time.time()
elapsed_time = now - self._start_time
if self._seshlen > elapsed_time:
time.sleep(self._seshlen - elapsed_time)
else:
time.sleep(2)
else:
char = self._stdscr.getch()
if self._mode == 'normal':
self._handle_normal_keypress(char)
elif self._mode == 'insert':
self._handle_insert_keypress(char)
# TODO: get rid of this position clipping
num_lines = len(self._buf.get_lines())
# This is a workaround. If we press 'j' on the last line, it will
# add another new line for us. This works around having to figure
# out if we're on the last line to add a new line.
row_max = min(num_lines - 1, max(0, self._row))
if self._row > row_max:
self._buf._lines.append('')
self._row = min(num_lines, max(0, self._row))
# on empty lines, still allow col 1
num_cols = max(1, len(self._buf.get_lines()[self._row]))
# in insert mode, allow using append after the last char
if self._mode == 'insert':
num_cols += 1
self._col = min(num_cols - 1, max(0, self._col))
@contextmanager def use_curses():
"""Context manager to set up and tear down curses."""
stdscr = curses.initscr()
curses.noecho() # do not echo keys
curses.cbreak() # don't wait for enter
try:
yield stdscr
finally:
# clean up and exit
curses.nocbreak()
stdscr.keypad(0)
curses.echo()
curses.endwin()
def move(target, current):
n = target - current
if target > current:
return (n, ['j'] * n)
elif target < current:
return (n, ['k'] * n)
else:
return (n, []) # On correct line
def curses_main():
"""Start the curses GUI."""
parser = argparse.ArgumentParser(description='Process some integers.')
parser.add_argument('--diff', type=argparse.FileType('r'), help="Path to the diff to apply")
parser.add_argument('--file', type=argparse.FileType('r'), help="Path to file to edit (conflicts with --dif)")
parser.add_argument('--speed', type=float, default=0.01, help="Time to sleep between button presses")
parser.add_argument('--session-min-length', type=float, default=0, help="The minimum time of the recording session (for syncing with audio.) Will sleep until this time has been reached.")
parser.add_argument('--debug', action='store_true', help="Print out character stream and exit")
parser.add_argument('--nosave', action='store_true', help="Do not save the output")
args = parser.parse_args()
stream = None
fn = None
if args.file:
fn = args.file.name
if args.diff:
p = list(whatthepatch.parse_patch(args.diff.read()))
if len(p) != 1:
raise Exception("Uhh can't parse this")
(header, changes, text) = p[0]
stream = []
fn = header.new_path
if header.new_path == '/dev/null':
raise Exception('Removing files is unsupported!')
elif header.old_path == '/dev/null' or header.old_path == header.new_path:
# If there is a set of folders, we need to make it.
if '/' in header.new_path:
directory = os.path.dirname(header.new_path)
os.makedirs(directory, exist_ok=True)
current_line = 1
line_delta = 0
for c in changes:
if c.new is None:
if args.debug:
stream.append(f'CASE A/- move {c.old + line_delta}<-{current_line}')
# print(f'removing line {c.old}: {c.line}')
(motion_count, motions) = move(c.old + line_delta, current_line)
stream.extend(motions)
current_line += motion_count
stream.extend(['x'] * len(c.line))
stream.extend(['i', chr(CHAR_BKSP), chr(CHAR_ESC), '0'])
current_line -= 1
line_delta -= 1
elif c.old is None:
if args.debug:
stream.append(f'CASE B/+ move {c.new}<-{current_line}')
(motion_count, motions) = move(c.new, current_line)
stream.extend(motions)
current_line = c.new
stream.append('O') # Enter edit mode
stream.extend(c.line)
stream.append(chr(CHAR_ESC)) # Return to normal
stream.append('0') # Ensure at start of line
else:
line_delta = c.new - c.old
# if args.debug:
# stream.append('CASE C/=')
# stream.extend(move(c.new, current_line))
# current_line = c.new
pass
# print(f'No change?? {c.old} {c.new} {c.line}')
if args.debug:
print(c)
stream.append(f'DEBUG: {current_line}/{line_delta}')
print(stream)
stream = []
else:
raise Exception("Cannot handle renames")
if args.debug:
print(stream)
sys.exit()
# Just in case.
stream.append(chr(CHAR_ESC))
stream = list(map(ord, stream))
with use_curses() as stdscr:
gui = EditorGUI(stdscr, fn, stream=stream, speed=args.speed, nosave=args.nosave, seshlen=args.session_min_length)
gui.main()
if __name__ == ‘__main__’:
curses_main()