✍️

Made with 120 lines of Python program MOD extensible fullscreen editor

2023/05/22に公開

This is the English version of an article I wrote in Japanese. Though I wrote it while checking the output of AI translation, Please point out any strange expressions.

Introduction

  I was thinking whether "note" (Japanese text-based SNS) or Zenn (this site) would be a good place to move from Qiita ( Japanese IT tech SNS)... and there was a voice pushing for Zenn, so I decided to write this document as a practice.

  The subject is, as the title says, a full-screen editor written in 120 lines of Python code that can be expanded with Mod. 120 lines is the size of the main part of the editor, not included the MOD programs. Development is being done on MacOS (so I am checking the operation on a MacOS terminal).

The structure of this document is as follows:

  • Source code of the main body of the editor
  • Program description of the editor
  • Source code of various extension mods (and some explanations)

The program is small in scale, so it is easy to understand!.

Source code for editor itself

  The source code of the main part is as follows. 120 lines, so It might not be need to post it on GitHub... so I'll just paste it in the article. The whole file structure including mods is like this.

.
├── 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: pthon ae2023.py targetFilePath")
    else:
        editer=Editer(); curses.wrapper(editer.Main)

Explanation of the editor itself

About the whole thing (about the same parts as the C++ version)

  Since this is just a port from the C++ version, please refer to this explanation for the most part.

English translation version by Google
https://qiita-com.translate.goog/iigura/items/678aca225956272bdc10?_x_tr_sl=auto&_x_tr_tl=ja&_x_tr_hl=ja&_x_tr_pto=wapp#コード解説

Original document (written in Japanese)
https://qiita.com/iigura/items/678aca225956272bdc10#コード解説

This time, in order to support Mod extensions, the functions of the text editor have been combined into an Editor object, so there are some changes in that area. But, well, the names are the same, so you can understand.

What is considered a Mod file?

  One feature that is not included in the C++ version is the support for extensions by external files (Mod). In porting from C++ to Python, which is an interpreted language, I could have ported it as it is, but I modify that support Mod extensions because Python is interpreter-type programinng language.

The main part related to Mod is the Install function on line 97. The following is an excerpt:

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)

  Every Mod files shall be stored in the mod directory which is the same location as the editor program ae2023.py. A mod file is defines ad a file that is stored in the “mod” directory and has the extension ".py".

The glob package is used as a way to enumerate all files in a particular directory.

Order of Mod loading

  Actually, the order in which mods are loaded is important. As will be explained later, a mod called help, which displays a list of key bindings, is also shown in this example as a reference implementation. help Mod should also display the functions added by the Mods after all Mods for the operation have been applied. In other words, all mods with additional keybindings must be installed when the help mod is installed.

It may be possible to implement a function to automatically determine which mods a particular mod depends on, but I am not confident that I can implement such a function in a compact manner. For this reason, the design this time is to define the installation order by file name order and leave the order of mod installation up to the user. For this reason, the InstallMod() shown above uses sorted to sort the file names.

How to call functions described in a Mod file

  I used importlib.import_module to read Python code from a file name stored in a string. I did some searching on how to use this function, but found only fragmentary or exhaustive information, so it took some time. Apparently, import_module() argument is seems to be the directory name +". "+ base file name without the extension. For example, to import 90_help.py, use import_module("mod.90_help").

The return value of import_module() should be saved, and the function name defined in the external file should be written as a method call. InstallMod() shown above uses module.Install(self), which calls the Install function described in the external file (in this case, various mod files) with the Editor object as an argument.

How it work with MODs

About text editor operation and state acquisition

  By the above method, the Install function implemented in the mod file is called. The Editor object, which holds all information related to the editor, is passed as an argument. So each mod should keep it in a global variable or the like for its own use. All of the various information stored in the Editor object can be read and modified from the outside, so it can be referenced and manipulated by functions on the mod side.

How to start the extension

About keystrokes

  Like the C++ version, this editor is implemented in such a way that it consults the Action table...or more precisely, an associative array, in response to key input, and if a value is stored that is paired with the corresponding key code (ASCII code), the corresponding function is invoked using that value. The implementation is as follows.

This is different from the C++ version, but this time we wanted to display an explanation of the key mapping information in the help mod, so we used a tuple consisting of the function to be invoked and a description of the function as the value (of the key-value).

Therefore, when creating a Mod for an input system, a tuple consisting of a function to be called and a simple description must be registered in the Action associative array as the key code and related values. This should be understood by referring to the sample code (00_basic.py) shown in the next section.

related to screen display

  There are also extensions that do not involve keystrokes. For example, the status line display of an editor. In this case, the Display() function, which is in charge of screen display, calls the function (or lambda) stored in DisplayPostHook. This mechanism is used in 10_statusLine.py to display the status line.

Extension (Mod) source code and how to write a Mod

Mod Basics

  As briefly explained in the section on Mod-related explanations, if you give a file name that takes into account the order of loading and define the Install() function in the file, the file will be loaded and executed as a mod. Personally, I recommend prefix the file name with a two-digit number, assigning smaller numbers to higher priority files. It is a simple method, but easy to understand.

  On this project, I have written several Mods as samples, but basically, a global variable is defined to store the editor object, the editor object passed as an argument in the Install() function is stored in the global variable, and then, if necessary, actions and hooks are set. hooks as needed. That's all.

  The following is a description of each sample Mod, in the following order:

  • Overview of the Mod
  • Source code
  • Brief explanation of the source code

Additional basic functions (mod/00_basic.py)

  This mod provides the word-based left/right and page-based up/down functions that are standard in the C++ version. The code is a bit more complicated than the C++ version, because it is designed to be similar behavior to vim text editor in terms of left/right movement per word.

Source code

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")

Source Code Description

  It is the function Install() defined at the end this Mod program is invoked from ae2023.py in the main body. To make various information and functions available to the Mod side, an instance of the text editor object passed as an argument of the Install() function is stored into the global variable Editor.

It has already been mentioned that the Action associative array holds the function information to be invoked at the time of key input. Therefore, the various functions defined in this mod are added to the Action associative array along with the key codes to which they are assigned. This is all that is required for the linkage between the main body of the text editor and the Mod.

  The implementation of the wordLeft() and wordRight() functions is not very beautiful. My goal is to keep the program as short and concise as possible without being too much technical short coding such as a code golf. From this point of view, the situation is really not good.

This time, since it was implemented on the mod side, I did not pay much attention to the number of lines, so I implemented the same behavior as vim, which could not be done in the C++ version. In vim, not only spaces but also symbols are recognized as word separators, which I think adds to the complexity of the code. I am still trying to figure out how to make it more simple, especially when it comes to moving by word unit to the left side. If you know of a way to do the same thing and make it simpler, I would appreciate it if you would let me know (please contact me at Twitter @KojiSaito).

  string.punctuation is a set of delimiters. The actual value is '!" #$%&'()*+,-. /:;<=>? @[\]^_`{|}~', and this mod treats these characters as symbols for word division.

Status line display (mod/10_statusLine.py)

  The “basic" Mod shown above was an example of an extension that is activated by key input. The mods shown below are activated at the time of display and do not require explicit activation. The specific mechanism is based on DisplayPostHook in the Display() function defined in the body program.

The function (or lambda) defined in this variable is invoked each time the Display() function is called. This mod uses this mechanism to realize the so-called status line display.

Source code

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

Source Code Description

  Nothing special is done, only saving the editor object and setting the DisplayPostHook function in the Install() function. The function that will be set into the hook is the ShowStatusLine() function, which realizes the status line display.

  As a side note, when setting multiple DisplayPostHooks, it may recommend better to save the already set functions at Install() function and call them before calling a new hook function. By such an implementation, it may be realized hook functions to be called on like a daisy chain.

Display help (mod/90_help.py)

  The last one is a help mod that shows key bindings. This mod pauses the main body of the text editor while the extension is running (after displaying the list of keymaps, it displays HIT ANY KEY and waits for input from the user). The current implementation breaks if there is too much information defined in the keymap, but if you are interested, you may make your own modified version, too (it should not be that difficult).

In fact, one such mechanism is the shift to insert mode on the ‘i' key. In this editor, the i key is used to change to insert mode and to add and delete characters, which is actually only processed in the function assigned to the i key (the Insert() function in ae2023.py is responsible for this work ).

This help-Mod has the same structure as the Insert() function. Incidentally, all of the following functions implemented as standard functions can be provided as Mod.

  • Moving the cursor up, down, left, right, to the beginning or end of a line
  • Moving to the beginning or end of a document
  • Inserting and deleting characters, saving data being edited
  • Redrawing the screen and closing the text editor

Source code

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")

Source Code Description

  nothing special. The only thing I can say is that center(), a method for centering strings, is quite useful for creating TUI - Text User Interface.

Conclusion

  I started to write this as a way to practice... and it turned out to be a surprisingly large amount of text. But, well, I enjoy writing about programs or essays because I can write them in a very short time. In other words, it is the reverse of how much care I take when I write other sentences....

This time, I made an English version of my own article. There are pros and cons about AI, it is wonderful that I can transmit information in English like this, even though I am not good at English.

See you at the next article.

Discussion