!/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()