✏️

120 行の Python プログラムで作る Mod 拡張可能なフルスクリーンエディタ

2023/05/22に公開

はじめに

  Qiita からの引越し先として note が良いか Zenn が良いか…と考えていたら Zenn を押す声があり、練習がてら書いてみる。

  取り扱う題材はタイトルにもあるように、120 行の Python コードで記述された Mod で拡張可能なフルスクリーンエディタである。120 行はエディタ本体部分のコードサイズであり、Mod のプログラムは含んでいない。curses ライブラリを使い、開発は MacOS で行っている(なので、動作確認は MacOS のターミナル上で行っている)。

本文書の構成は以下のとおり:

  • エディタ本体のソースコード
  • エディタ本体のプログラム解説
  • 各種拡張 Mod のソースコード(と、ちょっとした解説)

規模の小さなプログラムなので、理解するのも簡単!(…だと思う)。

エディタ本体のソースコード

  Zenn は行番号でないのかな? まあいいや。

本体部分のソースコードは以下の通り。120 行だし GitHub に上げるまでもないか…ということで、記事中にベタに貼ってしまおう。ちなみに Mod を含めた全体のファイル構成はこんな感じ。mod ディレクトリ中に各種 Mod を放り込んでおけば OK。

.
├── ae2023.py
└── mod
    ├── 00_base.py
    ├── 10_statusLine.py
    └── 90_help.py

ae2023.py
# ae2023.py - A Python version of Anthony's Editor that support extended by Mod.
# by Koji Saito

import os, curses, sys, glob, importlib

class Editer:
    def __init__(self):
        self.kESC=27; self.kCR=13; self.kBS=8; self.kDEL=127
        self.kCtrlQ=17; self.kCtrlR=18; self.kCtrlS=19;
        self.TabSize=4
        self.StatusLine = self.FileName = ""
        self.Buf=[]
        self.Done=False
        self.Index = self.PageStart = self.PageEnd = self.Row = self.Col = 0
        self.StdScr=None; self.DisplayPostHook=lambda:None
        self.Action={
            ord('h'):(self.Left, "move cursor left"),
            ord('l'):(self.Right,"move cursor right"),
            ord('k'):(self.Up,   "move cursor up"),
            ord('j'):(self.Down, "move cursor down"),
            ord('0'):(self.LineBegin,"move cursor to beginning of line"),
            ord('$'):(self.LineEnd,  "move cursor to end of line"),
            ord('t'):(self.Top,      "move cursor to top of the text"),
            ord('G'):(self.Bottom,   "move cursor to end of the text"),
            ord('i'):(self.Insert,   "be insert mode (hit ESC key to exit)"),
            ord('x'):(self.Del,      "delete a character"),
            self.kCtrlS:(self.Save,  "save file"),
            self.kCtrlQ:(self.Quit,  "quit"),
            self.kCtrlR:(self.Redraw,"redraw the screen")
        }

    def LineTop(self,currentOffset):
        offset=currentOffset-1
        while offset>=0 and self.Buf[offset]!='\n': offset-=1
        return 0 if offset<=0 else offset+1

    def NextLineTop(self,currentOffset):
        offset=currentOffset
        while offset<len(self.Buf) and self.Buf[offset]!='\n': offset+=1
        return offset if offset<len(self.Buf) else len(self.Buf)-1

    def Adjust(self,currentOffset,currentCol):
        offset=currentOffset; i=0
        while offset<len(self.Buf) and self.Buf[offset]!='\n' and i<currentCol:
            i+=self.TabSize-(i&(self.TabSize-1)) if self.Buf[offset]=='\t' else 1; offset+=1
        return offset

    def Display(self):
        if self.Index<self.PageStart: self.PageStart=self.LineTop(self.Index)
        if self.PageEnd<=self.Index:
            self.PageStart=self.NextLineTop(self.Index)
            n = curses.LINES-2 if self.PageStart==len(self.Buf)-1 else curses.LINES 
            for t in range(n): self.PageStart=self.LineTop(self.PageStart-1)
        self.StdScr.move(0,0); i=j=0
        p = self.PageEnd = self.PageStart
        while not (curses.LINES-1<=i or len(self.Buf)<=self.PageEnd):
            if self.Index==self.PageEnd: self.Row=i; self.Col=j
            if self.Buf[p]!='\r':
                self.StdScr.addstr(i,j,self.Buf[p]);
                j += self.TabSize-(j&(self.TabSize-1)) if self.Buf[p]=='\t' else 1
            if self.Buf[p]=='\n' or curses.COLS<=j: i+=1; j=0
            self.PageEnd+=1; p+=1
        self.StdScr.clrtobot()
        if i<curses.LINES-1: self.StdScr.addstr(i,0,"<< EOF >>")
        self.StdScr.move(self.Row,self.Col); self.StdScr.refresh();
        self.DisplayPostHook()

    def Left(self):  self.Index=max(0,self.Index-1)
    def Right(self): self.Index=min(len(self.Buf),self.Index+1)
    def Up(self):self.Index=self.Adjust(self.LineTop(self.LineTop(self.Index)-1),self.Col)
    def Down(self):
        self.Index=self.NextLineTop(self.Index);self.Right();
        self.Index=self.Adjust(self.Index,self.Col)
    def LineBegin(self): self.Index=self.LineTop(self.Index);
    def LineEnd(self):   self.Index=self.NextLineTop(self.Index); self.Left();
    def Top(self):       self.Index=0
    def Bottom(self): self.Index=len(self.Buf)-1
    def Del(self): self.Buf.pop(Index) if self.Index<len(self.Buf)-1 else None
    def Quit(self):self.Done=True
    def Redraw(self): self.StdScr.clear(); self.Display()

    def Insert(self):
        ch=self.StdScr.getch()
        while ch!=self.kESC:
            if self.Index>0 and (ch==self.kBS or ch==self.kDEL):
                self.Index-=1; del self.Buf[self.Index]
            else:
                self.Buf.insert(self.Index,'\n' if ch==self.kCR else chr(ch))
                self.Index+=1
            self.StatusLine=" [ INSERT ] ";self.Display()
            ch=self.StdScr.getch()

    def Save(self):
        with open(self.FileName,mode='w') as f: f.write(''.join(self.Buf))
        self.StatusLine=" [ SAVED ] "

    def InstallMod(self):
        files=sorted(glob.glob("./mod/*"))
        for fname in files:
            s=os.path.basename(fname)
            if os.path.splitext(s)[1]!=".py": continue
            module=importlib.import_module('mod.'+os.path.splitext(s)[0])
            module.Install(self)

    def Main(self,stdscr):
        curses.raw()
        self.StdScr=stdscr
        self.FileName=sys.argv[1]
        self.InstallMod()
        with open(self.FileName) as f: self.Buf=list(f.read())
        while not self.Done:
            self.Display(); self.StatusLine=None
            ch=stdscr.getch(); self.Action[ch][0]() if ch in self.Action else None

if __name__ == "__main__":
    if len(sys.argv)!=2:
        print("usage: python ae2023.py targetFilePath")
    else:
        editer=Editer(); curses.wrapper(editer.Main)

エディタ本体の解説

全体について(C++ 版と同じ部分について)

  C++ 版から移植しただけなので、大部分はこちらの解説を参照して欲しい。

https://qiita.com/iigura/items/678aca225956272bdc10#コード解説

今回は Mod 拡張に対応するため、テキストエディタの機能を Editor オブジェクトにまとめているので、その辺りが若干変更になっている。でもまあ、名前も同様にしてあるので分かると思う。

Mod 関連の解説

何を Mod ファイルとするか

  C++ 版に無い機能としては外部ファイル(Mod)による機能拡張への対応部分がある。今回、C++ からインタプリタ形式の言語である Python に移植するにあたり、そのまま移植することもできたが折角なのでインタプリタ型ならではの機能拡張としてこのような修正を試みた。

  Mod に関する主な部分は 97 行目にある Install 関数である。以下にその部分を抜粋して示す:

def InstallMod(self):
    files=sorted(glob.glob("./mod/*"))
    for fname in files:
        s=os.path.basename(fname)
        if os.path.splitext(s)[1]!=".py": continue
        module=importlib.import_module('mod.'+os.path.splitext(s)[0])
        module.Install(self)

Mod ファイルはエディタ本体である ae2023.py と同じ場所にある mod ディレクトリ中に格納されているものとする。当該ディレクトリに格納されており、かつ、拡張子に ".py" を持つファイルを Mod ファイルとする。特定のディレクトリ中にあるファイルを全て列挙する方法として glob パッケージを利用している。glob の使い方はこちらのページに分かりやすい説明があった:

Python3入門 フォルダ内のファイル一覧を取得する方法

上記のページの他にも「python ディレクトリ中のファイル」等として検索すれば様々なページが見つかるので、お好みに応じて参考にすれば良いと思う。

Mod の読み込み順序

  実は、Mod の読み込み順序は重要である。後ほど説明するが、キーバインドの一覧を表示する help という Mod も参考実装として今回例示している。help Mod は操作に関する全ての Mod が適用された後、それらの Mod で追加された機能についても表示して欲しい。つまり、help Mod のインストール時には、キーバインドの追加を伴う全ての Mod はインストールされていなければならない。

ある Mod がどの Mod に依存しているのか、それを自動的に判定する機能も頑張れば実装できるのかもしれないが、それをコンパクトに実装できる自信が今の私にはない。そのため今回はファイル名により順序を定め、Mod のインストール順序に関してはユーザーに委ねるというデザインにした。そのため、上に示した InstallMod() では sorted を用いてファイル名を並び替えるようにしている。

Mod ファイルに記述された関数の呼び出し方法

  文字列中に格納されたファイル名からの Python コードの読み込みは importlib.import_module を利用した。この関数の使い方について幾つか検索してみたのだが、断片的な情報もしくは網羅的な情報しか見つからず、少々手間取ってしまった。どうやら、import_module() の引数には、ディレクトリ名+"."+拡張子を取り除いたベースファイル名とすれば良いらしい。例えば、90_help.py を読み込みたければ import_module("mod.90_help") と記述する。

import_module() の戻り値を保存しておき、その値に対しメソッドの呼び出しとして外部ファイル中に定義されている関数名を書けば良い。上に示した InstallMod() では、module.Install(self) としているので、外部ファイル(今回は各種 Mod ファイル)中に記述されている Install 関数を Editor オブジェクトを引数として呼び出している。

Mod 実現の仕組み

テキストエディタの操作や状態取得について

  上記の方法にて、Mod ファイルに実装された Install 関数が呼び出される。このとき引数としてエディタに関わる全ての情報を保持している Editor オブジェクトが引数として渡されるので、各 Mod 内ではそれをグローバル変数等に保持しておき活用する。Editor オブジェクトに格納している様々な情報は全て外部から読み取り・変更できるようにしているので、Mod 側の関数により参照・操作が可能である。

拡張機能の起動方法

キー入力に関して

  本エディタも C++ 版と同様、キー入力に応じて Action テーブル…というか正確には連想配列を参照し、対応するキーコード(ASCII コード)と対を成す値が格納されていれば、それを用いて対応する関数を起動する ー という実装になっている。

C++ 版と異なる部分であるが、今回は help Mod でキーマッピング情報の説明も表示したかったので、キーコードに対する値として、起動する関数と当該機能の説明文からなるタプルを key-value の value としている。

そのため、入力系の Mod を作る場合は、Action 連想配列にキーコードと関連する値として、呼び出す関数と簡単な説明文からなるタプルを登録しなければならない。このあたりは次節に示されているサンプルコード(00_basic.py)を参照すれば分かると思う。

画面表示に関するもの

  キー入力を伴わない機能拡張も存在する。例えばエディタのステータスライン表示などである。今回は画面表示を司る Display() 関数中にて DisplayPostHook に格納された関数(もしくはラムダ)を呼び出すようにした。この仕組みを用いて 10_statusLine.py ではステータスライン表示を実現している。

拡張機能(Mod)のソースコード および Mod の書き方

Mod の基本

  Mod 実現の仕組みのところでも簡単に説明しているが、読み込み順序を考慮したファイル名を与え、そのファイル中には Install() 関数を定義すれば Mod としての読み込みおよび実行が行われる。個人的にはファイル名の先頭に 2 桁の数字を付しておき、優先度の高いものほど小さな数値を割り当てるという方法である。単純な方法ではあるが分かりやすい。

  今回いくつかサンプルとして Mod を書いてみたが、基本的に、エディタオブジェクトを格納するグローバル変数を定義しておき、Install() 関数中にて引数として渡されるエディタオブジェクトをそのグローバル変数に保存し、その後、必要に応じてアクションやフックを設定する。ただこれだけである。

  以下、それぞれのサンプルの Mod について示していくが、それぞれの Mod において、次の順序で説明を行う:

  • Mod の概要
  • ソースコード
  • 簡単な解説

追加の基本的機能(mod/00_basic.py)

  この Mod は、C++ 版では標準装備していたワード単位の左右移動ならびにページ単位での上下移動を実現するものである。ページ単位での上下移動は C++ と同じだが、ワード単位での左右移動については vim と同様になるように努力しており、少々コードが複雑になっている。

ソースコード

00_basic.py
import string
import curses

Editor=None

isPunc=lambda c:c in string.punctuation
isWordHeadIndex=lambda i:i==0 or Editor.Buf[i-1].isspace() or isPunc(Editor.Buf[i-1])

kCtrlU=21
kCtrlD=4

def wordLeft():
    buf=Editor.Buf
    i=Editor.Index
    if buf[i].isspace():
        while buf[i].isspace() and i>0: i-=1
        while isWordHeadIndex(i)==False: i-=1
    elif isPunc(buf[i]):
        if i>0: i-=1
        while buf[i].isspace() and i>0: i-=1
    elif i>0:
        i-=1
        if buf[i].isspace()==False:
            if isPunc(buf[i])==False:
                while buf[i].isspace()==False and isPunc(buf[i])==False and i>0: i-=1
                i+=1
        else:
            while buf[i].isspace() and i>0: i-=1
            if isPunc(buf[i])==False:
                while buf[i].isspace()==False and isPunc(buf[i])==False and i>0: i-=1
                i+=1
    Editor.Index=i

def wordRight():
    buf=Editor.Buf
    i=Editor.Index
    if buf[i].isspace():
        while buf[i].isspace() and i<len(buf)-1: i+=1
    elif isPunc(buf[i]):
        while isPunc(buf[i]) and i<len(buf)-1: i+=1
        if buf[i].isspace():
            while buf[i].isspace() and i<len(buf)-1: i+=1
    else:
        while isPunc(buf[i])==False and buf[i].isspace()==False and i<len(buf)-1: i+=1
        while buf[i].isspace() and i<len(buf)-1: i+=1
    Editor.Index=i

def pageUp():
    i=curses.LINES-1
    while i>0:
        Editor.PageStart=Editor.LineTop(Editor.PageStart-1)
        Editor.Up()
        i-=1

def pageDown():
    Editor.PageStart=Editor.Index=Editor.LineTop(Editor.PageEnd-1)
    while Editor.Row>0: Editor.Down(); Editor.Row-=1
    Editor.PageEnd=len(Editor.Buf)-1

def Install(editor):
    global Editor
    Editor=editor
    Editor.Action[ord('b')]=(wordLeft, "move cursor one word to the left")
    Editor.Action[ord('w')]=(wordRight,"move cursor one word to the right")
    Editor.Action[kCtrlU]  =(pageUp,   "move up one page")
    Editor.Action[kCtrlD]  =(pageDown, "move down one page")

ソースコード解説

  本体の ae2023.py より起動されるのは最後に定義してある関数 Install() である。各種の情報や機能を Mod 側で利用できるよう、Install() 関数の引数として渡されるテキストエディタオブジェクトのインスタンスをグローバル変数 Editor に保存している。

キー入力時に起動される関数情報を保持しているのが Action 連想配列であることは既に述べたとおりである。そのため、本 Mod で定義する各種の機能を割り当てるキーコードと共に Action 連想配列に追加する。テキストエディタ本体と Mod の連携部分に関してはこれだけである。

  wordLeft() 関数ならびに wordRight() 関数の実装があまり美しくない。技巧的になりすぎないように、できる鍵値簡潔に短く記述する - これが私の目指すプログラムである。そのような観点からすると、誠に宜しくない状況である。

今回は Mod 側への実装ということもあり、行数についてはそれほど意識しないようにした。そのため、C++ 版ではできなかった vim と同様の動作をする実装を行った。vim では単語の区切りとして空白のみならず記号も区切りとして認識される。これがコードの複雑性を増していると考えている。特にワード単位での左側への移動に関して複雑となっており、このあたりを何とかしたいなあ、と思いつつ現状に至る。同じ動作をし、かつ、より簡潔に書ける方法をご存知の方は教えて頂ければ幸いである(Twitter @KojiSaito までご連絡頂ければ、と思う)。

  string.punctuation というのは区切り記号の集合である。実際の値は '!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~' であり、本 Mod ではこれらの文字をワードを分割するシンボルとして取り扱っている。

ステータスライン表示(mod/10_statusLine.py)

  上に示した basic Mod は、キー入力により起動する機能拡張の例であった。以下に示す Mod は表示の際に起動されるものであり、明示的な起動を必要としない。具体的な仕組みについては本体側のプログラムで定義されている Display() 関数内の DisplayPostHook を利用している。

この変数に定義されている関数(またはラムダ)は Display() 関数が呼ばれる度に起動される。この Mod ではその性質を利用し、いわゆるステータスライン表示を実現している。

ソースコード

10_statusLine.py
import curses

Editor=None
def ShowStatusLine():
    n=curses.COLS-1
    if Editor.StatusLine!=None:
        s=str(Editor.StatusLine).ljust(n)
    else:
        currentLineTopIndex=Editor.LineTop(Editor.Index)
        i=y=0
        while i<currentLineTopIndex: i=Editor.NextLineTop(i)+1; y+=1
        s=" ROW="+str(y)+" COL="+str(Editor.Col)+" "
        s=s.rjust(n-len(Editor.FileName)-1)
        s=" "+Editor.FileName+s
    Editor.StdScr.addstr(curses.LINES-1,0,s,curses.A_REVERSE)
    Editor.StdScr.move(Editor.Row,Editor.Col)
    Editor.StdScr.refresh()

def Install(editor):
    global Editor;
    Editor=editor
    editor.DisplayPostHook=ShowStatusLine

ソースコード解説

  何ら特別なことは行ってはおらず、Install() 関数内でエディタオブジェクトの保存と、DisplayPostHook 関数の設定を行っているのみである。設定される関数は ShowStatusLine() 関数であり、この関数がステータスライン表示を実現しているだけである。

  余談であるが、DisplayPostHook を複数設定する場合、既に設定されている関数を Install() 関数無いで保存しておき、新しいフック関数を呼び出す前にそれらを呼び出すなどした方が良いかもしれない。このような実装にすればフック関数を数珠繫ぎ的に呼び出すことが可能である。

ヘルプ表示(mod/90_help.py)

  最後はキーバインドを示す help Mod である。この Mod は機能拡張実行中に、テキストエディタ本体側の一時停止を行うものである(キーマップの一覧を表示したら HIT ANY KEY と表示し、ユーザーからの入力を待つ)。現在の実装では、キーマップに定義されている情報が多い場合は破綻してしまうが、気になる人は修正版を自分で作ってみると良いと思う(そんなに難しくは無いはず)。

実はこのような形のものとして、i キーにおける挿入モードへの移行がある。本エディタでは i キーを押下して
挿入モードに変更し、文字の追加削除ができるようになっているが、実はそれは i キーに割り当てられた関数内で完結しているものである(ae2023.py の Insert() 関数がその実態)。

構造としてはこの help Mod も Insert() 関数と同様の構造を持つものである。ちなみに、標準機能として実装してある以下の機能は全て Mod として提供可能である。

  • カーソルの上下左右の移動ならびに行頭・行末への移動
  • ドキュメント先頭ならびに末尾への移動
  • 文字の挿入・削除、編集中のデータの保存
  • 画面の再描画、テキストエディタの終了処理

ソースコード

90_help.py
import curses

Editor=None

def showHelp():
    stdscr=Editor.StdScr
    action=Editor.Action
    stdscr.clear()
    stdscr.addstr(1,0," KEY MAP ".center(curses.COLS-1,"="))
    y=3
    for i in range(26):
        if i in action:
            stdscr.addstr(y,3,"Ctrl-"+str(chr(i+64))+"  "+action[i][1])
            y+=1
    for i in range(33,126):
        if i in action:
            stdscr.addstr(y,3,"     "+str(chr(i))+"  "+action[i][1])
            y+=1
    stdscr.addstr(y+1,0," HIT ANY KEY ".center(curses.COLS-1,"-"))
    stdscr.refresh()
    stdscr.getch()

def Install(editor):
    global Editor
    Editor=editor
    Editor.Action[ord('?')]=(showHelp,"show this help message")

ソースコード解説

  特になし。強いて言えば文字列に対する中央揃えのメソッド center() は、TUI - Text User Interface を作る際にはなかなか便利である、ということぐらいであろうか。

Pythonで文字列・数値を右寄せ、中央寄せ、左寄せ

おわりに

  練習がてらに…と思って書き始めたら意外と文量が多くなってしまった。でもまあ、プログラムに関する話というかエッセイ的なものはざざっと短時間に書けるので楽しい。言い換えれば、そうでない文章を書く時にどれだけ気を使って書いているのか…という事の裏返しでもあるのだが。

  Zenn を今回使ってみて、note よりも高速に記事が書けるように感じた。全体的に若干キビキビと動作している感じ。このあたりは、Zenn".dev" となっていることからも技術者むけにチューニングしてあるのかな?とも感じた。また、個人的にはダークモードにも対応して欲しいと思う。Issue をみると数年前から検討はされているようであるが、進展なさそうである。是非とも実装して欲しいものである。

Issue ダークモードが欲しい #267

Discussion