LoginSignup
2
6

More than 5 years have passed since last update.

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

Posted at

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

120 行で vi っぽいエディタを作る を見て面白そうだったので、Python で書いてみた。とりあえず動くレベル。個人的メモ。

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

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

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

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

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])
2
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
6