[WIP] 200 行で vi っぽいエディタを作る(python)

# [WIP] 200 行で vi っぽいエディタを作る (python)

[120 行で vi っぽいエディタを作る](https://qiita.com/iigura/items/678aca225956272bdc10) を見て面白そうだったので、Python で書いてみた。とりあえず動くレベル。個人的メモです。

手元の環境が古かったのか、変なところでこけたりする(`addch()` で `'\n'` を描画すると ERR が返ってきた)ので、こまごま修正しました。

エラーハンドリングはほとんどしていないので(例：ファイル出力でエラーになるとこける、など)まったく実用にはなりません。

扱う文字範囲は、ASCII の範囲内で。

終了は Ctrl+Q。それ以外の操作は、ソース参照。

```python:ae2019.py
#!/usr/bin/env python3
import sys
import os
import curses
import curses.ascii as asc

def ctrl(ch):
    return ord(ch) & 0x1f

class Editor:
    file_name = ''
    buff = []
    done = False
    index, page_start, page_end = 0, 0, 0
    col, row = 0, 0
    actions = {}
    stdscr = None

    def __init__(self):
        self.actions = {
            ord('h'): self.left,
            ord('l'): self.right,
            ord('k'): self.up,
            ord('j'): self.down,
            ord('b'): self.word_left,
            ord('w'): self.word_right,
            ctrl('D'): self.page_down,
            ctrl('U'): self.page_up,
            ord('0'): self.line_begin,
            ord('$'): self.line_end,
            ord('t'): self.top,
            ord('G'): self.bottom,
            ord('i'): self.insert,
            ord('x'): self.delete,
            ctrl('Q'): self.quit,
            ctrl('R'): self.redraw,
            ctrl('S'): self.save,
        }

    def line_top(self, in_offset):
        for offset in reversed(range(0, in_offset)):
            if self.buff[offset] == '\n':
                return offset+1
        return 0

    def next_line_top(self, in_offset):
        for offset in range(in_offset, len(self.buff)):
            if self.buff[offset] == '\n':
                return min(offset+1, len(self.buff)-1)
        return len(self.buff)-1

    def adjust(self, top_offset, in_col):
        x = 0
        for offset in range(top_offset, len(self.buff)):
            if in_col <= x or self.buff[offset] == '\n':
                return offset
            x += (8-(x&7)) if self.buff[offset] == '\t' else 1
        return len(self.buff)-1

    def display(self):
        if self.index < self.page_start:
            self.page_start = self.line_top(self.index)
        if self.page_end <= self.index:
            self.page_start = self.next_line_top(self.index)
            n = (curses.LINES-2) if self.page_start == len(self.buff)-1 \
                else curses.LINES
            for i in range(0, n):
                self.page_start = self.line_top(self.page_start-1)
        self.stdscr.erase()
        (y, x) = (0, 0)
        for self.page_end in range(self.page_start, len(self.buff)):
            if curses.LINES <= y:
                break
            ch = self.buff[self.page_end]
            if self.index == self.page_end:
                (self.row, self.col) = (y, x)
            if ch != '\r' and ch != '\n':
                self.stdscr.addch(y, x, ch)
                x += (8-(x&7)) if ch == '\t' else 1
            if ch == '\n' or curses.COLS <= x:
                (y, x) = (y+1, 0)
        if y+1 < curses.LINES:
            self.stdscr.addstr(y+1, 0, '<< EOF >>')
        self.stdscr.move(self.row, self.col)
        self.stdscr.refresh() 

    def left(self):
        self.index = max(self.index-1, 0)

    def right(self):
        self.index = min(self.index+1, len(self.buff)-1)

    def up(self):
        current_top = self.line_top(self.index)
        prev_top = self.line_top(current_top-1)
        self.index = self.adjust(prev_top, self.col)

    def down(self):
        next_top = self.next_line_top(self.index)
        self.index = self.adjust(next_top, self.col)

    def line_begin(self):
        self.index = self.line_top(self.index)

    def line_end(self):
        self.index = max(self.next_line_top(self.index)-1, 0)

    def top(self):
        self.index = 0

    def bottom(self):
        self.index = len(self.buff)-1

    def delete(self):
        if self.index < len(self.buff)-1:
            del(self.buff[self.index])

    def quit(self):
        self.done = True

    def redraw(self):
        curses.clear()
        self.display()

    def insert(self):
        while True:
            code = self.stdscr.getch()
            if code == asc.ESC:
                break
            elif 0 < self.index and code in [asc.BS, asc.DEL]:
                self.index -= 1
                del(self.buff[self.index])
            else:
                if len(self.buff) == 0:
                    self.buff = [ '\n' ]
                ch = '\n' if code == ord('\r') else chr(code)
                self.buff.insert(self.index, ch)
                self.index += 1
            self.display()

    def word_left(self):
        while 0 < self.index and not self.buff[self.index].isspace():
            self.index -= 1
        while 0 < self.index and self.buff[self.index].isspace():
            self.index -= 1

    def word_right(self):
        while self.index < len(self.buff)-1 \
                and not self.buff[self.index].isspace():
            self.index += 1
        while self.index < len(self.buff)-1 \
                and self.buff[self.index].isspace():
            self.index += 1

    def page_down(self):
        self.page_start = self.index = self.line_top(self.page_end-1)
        for i in range(0, self.row):
            self.down()
        self.page_end = len(self.buff)-1

    def page_up(self):
        for i in range(0, curses.LINES):
            self.page_start = self.line_top(self.page_start-1)
            self.up()

    def save(self):
        with open(self.file_name, mode='w') as f:
            f.write(''.join(self.buff))

    def main(self, stdscr, file_name):
        self.stdscr = stdscr
        curses.raw()
        self.stdscr.idlok(True)
        self.file_name = file_name
        if os.path.isfile(self.file_name):
            with open(self.file_name) as f:
                self.buff = list(f.read())
        else:
            self.buff = []
        while not self.done:
            self.display()
            code = self.stdscr.getch()
            if code in self.actions:
                self.actions[code]()
 
if __name__ == '__main__':
    if len(sys.argv) < 2:
        raise Exception("usage: ae2019.py FILE_NAME")
    curses.wrapper(Editor().main, sys.argv[1])
```