Posted at

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


[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])