From 9a049d4f9573bd56a5bd5323689f2c2780b788ac Mon Sep 17 00:00:00 2001 From: Qi Xiao Date: Tue, 8 Oct 2024 16:45:06 +0100 Subject: [PATCH] binding map PoC --- pkg/etk/run.go | 5 + pkg/etkedit/buffer_builtins.go | 397 +++++++++++++++++++++++++++++++++ pkg/etkedit/edit.go | 25 +++ pkg/etkedit/old_api.go | 48 ++++ 4 files changed, 475 insertions(+) create mode 100644 pkg/etkedit/buffer_builtins.go create mode 100644 pkg/etkedit/old_api.go diff --git a/pkg/etk/run.go b/pkg/etk/run.go index dd365b246..cfde048ba 100644 --- a/pkg/etk/run.go +++ b/pkg/etk/run.go @@ -14,6 +14,7 @@ type RunCfg struct { Frame *eval.Frame MaxHeight int Justify Justify + ContextFn func(Context) } type Justify uint8 @@ -52,6 +53,10 @@ func Run(f Comp, cfg RunCfg) (vals.Map, error) { sc := Stateful(fm, f) defer sc.Finish() + if cfg.ContextFn != nil { + cfg.ContextFn(Context{sc.g, nil}) + } + for { // Render. h, w := tty.Size() diff --git a/pkg/etkedit/buffer_builtins.go b/pkg/etkedit/buffer_builtins.go new file mode 100644 index 000000000..4ebc600ef --- /dev/null +++ b/pkg/etkedit/buffer_builtins.go @@ -0,0 +1,397 @@ +package edit + +import ( + "strings" + "unicode" + "unicode/utf8" + + "src.elv.sh/pkg/cli/tk" + "src.elv.sh/pkg/etk/comps" + "src.elv.sh/pkg/strutil" + "src.elv.sh/pkg/wcwidth" +) + +var bufferBuiltinsData = map[string]func(*comps.TextBuffer){ + "move-dot-left": makeMove(moveDotLeft), + "move-dot-right": makeMove(moveDotRight), + "move-dot-left-word": makeMove(moveDotLeftWord), + "move-dot-right-word": makeMove(moveDotRightWord), + "move-dot-left-small-word": makeMove(moveDotLeftSmallWord), + "move-dot-right-small-word": makeMove(moveDotRightSmallWord), + "move-dot-left-alnum-word": makeMove(moveDotLeftAlnumWord), + "move-dot-right-alnum-word": makeMove(moveDotRightAlnumWord), + "move-dot-sol": makeMove(moveDotSOL), + "move-dot-eol": makeMove(moveDotEOL), + + "move-dot-up": makeMove(moveDotUp), + "move-dot-down": makeMove(moveDotDown), + + "kill-rune-left": makeKill(moveDotLeft), + "kill-rune-right": makeKill(moveDotRight), + "kill-word-left": makeKill(moveDotLeftWord), + "kill-word-right": makeKill(moveDotRightWord), + "kill-small-word-left": makeKill(moveDotLeftSmallWord), + "kill-small-word-right": makeKill(moveDotRightSmallWord), + "kill-alnum-word-left": makeKill(moveDotLeftAlnumWord), + "kill-alnum-word-right": makeKill(moveDotRightAlnumWord), + "kill-line-left": makeKill(moveDotSOL), + "kill-line-right": makeKill(moveDotEOL), + + "transpose-rune": makeTransform(transposeRunes), + "transpose-word": makeTransform(transposeWord), + "transpose-small-word": makeTransform(transposeSmallWord), + "transpose-alnum-word": makeTransform(transposeAlnumWord), +} + +// A pure function that takes the current buffer and dot, and returns a new +// value for the dot. Used to derive move- and kill- functions that operate on +// the editor state. +type pureMover func(buffer string, dot int) int + +func makeMove(m pureMover) func(*comps.TextBuffer) { + return func(buf *comps.TextBuffer) { + buf.Dot = m(buf.Content, buf.Dot) + } +} + +func makeKill(m pureMover) func(*comps.TextBuffer) { + return func(buf *comps.TextBuffer) { + newDot := m(buf.Content, buf.Dot) + if newDot < buf.Dot { + // Dot moved to the left: remove text between new dot and old dot, + // and move the dot itself + buf.Content = buf.Content[:newDot] + buf.Content[buf.Dot:] + buf.Dot = newDot + } else if newDot > buf.Dot { + // Dot moved to the right: remove text between old dot and new dot. + buf.Content = buf.Content[:buf.Dot] + buf.Content[newDot:] + } + } +} + +// A pure function that takes the current buffer and dot, and returns a new +// value for the buffer and dot. +type pureTransformer func(buffer string, dot int) (string, int) + +func makeTransform(t pureTransformer) func(*comps.TextBuffer) { + return func(buf *comps.TextBuffer) { + buf.Content, buf.Dot = t(buf.Content, buf.Dot) + } +} + +// Implementation of pure movers. + +func moveDotLeft(buffer string, dot int) int { + _, w := utf8.DecodeLastRuneInString(buffer[:dot]) + return dot - w +} + +func moveDotRight(buffer string, dot int) int { + _, w := utf8.DecodeRuneInString(buffer[dot:]) + return dot + w +} + +func moveDotSOL(buffer string, dot int) int { + return strutil.FindLastSOL(buffer[:dot]) +} + +func moveDotEOL(buffer string, dot int) int { + return strutil.FindFirstEOL(buffer[dot:]) + dot +} + +func moveDotUp(buffer string, dot int) int { + sol := strutil.FindLastSOL(buffer[:dot]) + if sol == 0 { + // Already in the first line. + return dot + } + prevEOL := sol - 1 + prevSOL := strutil.FindLastSOL(buffer[:prevEOL]) + width := wcwidth.Of(buffer[sol:dot]) + return prevSOL + len(wcwidth.Trim(buffer[prevSOL:prevEOL], width)) +} + +func moveDotDown(buffer string, dot int) int { + eol := strutil.FindFirstEOL(buffer[dot:]) + dot + if eol == len(buffer) { + // Already in the last line. + return dot + } + nextSOL := eol + 1 + nextEOL := strutil.FindFirstEOL(buffer[nextSOL:]) + nextSOL + sol := strutil.FindLastSOL(buffer[:dot]) + width := wcwidth.Of(buffer[sol:dot]) + return nextSOL + len(wcwidth.Trim(buffer[nextSOL:nextEOL], width)) +} + +func transposeRunes(buffer string, dot int) (string, int) { + if len(buffer) == 0 { + return buffer, dot + } + + var newBuffer string + var newDot int + // transpose at the beginning of the buffer transposes the first two + // characters, and at the end the last two + if dot == 0 { + first, firstLen := utf8.DecodeRuneInString(buffer) + if firstLen == len(buffer) { + return buffer, dot + } + second, secondLen := utf8.DecodeRuneInString(buffer[firstLen:]) + newBuffer = string(second) + string(first) + buffer[firstLen+secondLen:] + newDot = firstLen + secondLen + } else if dot == len(buffer) { + second, secondLen := utf8.DecodeLastRuneInString(buffer) + if secondLen == len(buffer) { + return buffer, dot + } + first, firstLen := utf8.DecodeLastRuneInString(buffer[:len(buffer)-secondLen]) + newBuffer = buffer[:len(buffer)-firstLen-secondLen] + string(second) + string(first) + newDot = len(newBuffer) + } else { + first, firstLen := utf8.DecodeLastRuneInString(buffer[:dot]) + second, secondLen := utf8.DecodeRuneInString(buffer[dot:]) + newBuffer = buffer[:dot-firstLen] + string(second) + string(first) + buffer[dot+secondLen:] + newDot = dot + secondLen + } + + return newBuffer, newDot +} + +func moveDotLeftWord(buffer string, dot int) int { + return moveDotLeftGeneralWord(categorizeWord, buffer, dot) +} + +func moveDotRightWord(buffer string, dot int) int { + return moveDotRightGeneralWord(categorizeWord, buffer, dot) +} + +func transposeWord(buffer string, dot int) (string, int) { + return transposeGeneralWord(categorizeWord, buffer, dot) +} + +func categorizeWord(r rune) int { + switch { + case unicode.IsSpace(r): + return 0 + default: + return 1 + } +} + +func moveDotLeftSmallWord(buffer string, dot int) int { + return moveDotLeftGeneralWord(tk.CategorizeSmallWord, buffer, dot) +} + +func moveDotRightSmallWord(buffer string, dot int) int { + return moveDotRightGeneralWord(tk.CategorizeSmallWord, buffer, dot) +} + +func transposeSmallWord(buffer string, dot int) (string, int) { + return transposeGeneralWord(tk.CategorizeSmallWord, buffer, dot) +} + +func moveDotLeftAlnumWord(buffer string, dot int) int { + return moveDotLeftGeneralWord(categorizeAlnum, buffer, dot) +} + +func moveDotRightAlnumWord(buffer string, dot int) int { + return moveDotRightGeneralWord(categorizeAlnum, buffer, dot) +} + +func transposeAlnumWord(buffer string, dot int) (string, int) { + return transposeGeneralWord(categorizeAlnum, buffer, dot) +} + +func categorizeAlnum(r rune) int { + switch { + case tk.IsAlnum(r): + return 1 + default: + return 0 + } +} + +// Word movements are are more complex than one may expect. There are also +// several flavors of word movements supported by Elvish. +// +// To understand word movements, we first need to categorize runes into several +// categories: a whitespace category, plus one or more word category. The +// flavors of word movements are described by their different categorization: +// +// * Plain word: two categories: whitespace, and non-whitespace. This flavor +// corresponds to WORD in vi. +// +// * Small word: whitespace, alphanumeric, and everything else. This flavor +// corresponds to word in vi. +// +// * Alphanumeric word: non-alphanumeric (all treated as whitespace) and +// alphanumeric. This flavor corresponds to word in readline and zsh (when +// moving left; see below for the difference in behavior when moving right). +// +// After fixing the flavor, a "word" is a run of runes in the same +// non-whitespace category. For instance, the text "cd ~/tmp" has: +// +// * Two plain words: "cd" and "~/tmp". +// +// * Three small words: "cd", "~/" and "tmp". +// +// * Two alphanumeric words: "cd" and "tmp". +// +// To move left one word, we always move to the beginning of the last word to +// the left of the dot (excluding the dot). That is: +// +// * If we are in the middle of a word, we will move to its beginning. +// +// * If we are already at the beginning of a word, we will move to the beginning +// of the word before that. +// +// * If we are in a run of whitespaces, we will move to the beginning of the +// word before the run of whitespaces. +// +// Moving right one word works similarly: we move to the beginning of the first +// word to the right of the dot (excluding the dot). This behavior is the same +// as vi and zsh, but differs from GNU readline (used by bash) and fish, which +// moves the dot to one point after the end of the first word to the right of +// the dot. +// +// See the test case for a real-world example of how the different flavors of +// word movements work. +// +// A remark: This definition of "word movement" is general enough to include +// single-rune movements as a special case, where each rune is in its own word +// category (even whitespace runes). Single-rune movements are not implemented +// as such though, to avoid making things unnecessarily complex. + +// A function that describes a word flavor by categorizing runes. The return +// value of 0 represents the whitespace category while other values represent +// different word categories. +type categorizer func(rune) int + +// Move the dot left one word, using the word flavor described by the +// categorizer. +func moveDotLeftGeneralWord(categorize categorizer, buffer string, dot int) int { + // skip trailing whitespaces left of dot + pos := skipWsLeft(categorize, buffer, dot) + + // skip this word + pos = skipSameCatLeft(categorize, buffer, pos) + + return pos +} + +// Move the dot right one word, using the word flavor described by the +// categorizer. +func moveDotRightGeneralWord(categorize categorizer, buffer string, dot int) int { + // skip leading whitespaces right of dot + pos := skipWsRight(categorize, buffer, dot) + + if pos > dot { + // Dot was within whitespaces, and we have now moved to the start of the + // next word. + return pos + } + + // Dot was within a word; skip both the word and whitespaces + + // skip this word + pos = skipSameCatRight(categorize, buffer, pos) + // skip remaining whitespace + pos = skipWsRight(categorize, buffer, pos) + + return pos +} + +// Transposes the words around the cursor, using the word flavor described +// by the categorizer. +func transposeGeneralWord(categorize categorizer, buffer string, dot int) (string, int) { + if strings.TrimFunc(buffer, func(r rune) bool { return categorize(r) == 0 }) == "" { + // buffer contains only whitespace + return buffer, dot + } + + // after skipping whitespace, find the end of the right word + pos := skipWsRight(categorize, buffer, dot) + var rightEnd int + if pos == len(buffer) { + // there is only whitespace to the right of the dot + rightEnd = skipWsLeft(categorize, buffer, pos) + } else { + rightEnd = skipSameCatRight(categorize, buffer, pos) + } + // if the dot started in the middle of a word, 'pos' is the same as dot, + // so we should skip word characters to the left to find the start of the + // word + rightStart := skipSameCatLeft(categorize, buffer, rightEnd) + + leftEnd := skipWsLeft(categorize, buffer, rightStart) + var leftStart int + if leftEnd == 0 { + // right word is the first word, use it as the left word and find a + // new right word + leftStart = rightStart + leftEnd = rightEnd + + rightStart = skipWsRight(categorize, buffer, leftEnd) + if rightStart == len(buffer) { + // there is only one word in the buffer + return buffer, dot + } + + rightEnd = skipSameCatRight(categorize, buffer, rightStart) + } else { + leftStart = skipSameCatLeft(categorize, buffer, leftEnd) + } + + return buffer[:leftStart] + buffer[rightStart:rightEnd] + buffer[leftEnd:rightStart] + buffer[leftStart:leftEnd] + buffer[rightEnd:], rightEnd +} + +// Skips all runes to the left of the dot that belongs to the same category. +func skipSameCatLeft(categorize categorizer, buffer string, pos int) int { + if pos == 0 { + return pos + } + + r, _ := utf8.DecodeLastRuneInString(buffer[:pos]) + cat := categorize(r) + return skipCatLeft(categorize, cat, buffer, pos) +} + +// Skips whitespaces to the left of the dot. +func skipWsLeft(categorize categorizer, buffer string, pos int) int { + return skipCatLeft(categorize, 0, buffer, pos) +} + +func skipCatLeft(categorize categorizer, cat int, buffer string, pos int) int { + left := strings.TrimRightFunc(buffer[:pos], func(r rune) bool { + return categorize(r) == cat + }) + + return len(left) +} + +// Skips all runes to the right of the dot that belongs to the same +// category. +func skipSameCatRight(categorize categorizer, buffer string, pos int) int { + if pos == len(buffer) { + return pos + } + + r, _ := utf8.DecodeRuneInString(buffer[pos:]) + cat := categorize(r) + return skipCatRight(categorize, cat, buffer, pos) +} + +// Skips whitespaces to the right of the dot. +func skipWsRight(categorize categorizer, buffer string, pos int) int { + return skipCatRight(categorize, 0, buffer, pos) +} + +func skipCatRight(categorize categorizer, cat int, buffer string, pos int) int { + right := strings.TrimLeftFunc(buffer[pos:], func(r rune) bool { + return categorize(r) == cat + }) + + return len(buffer) - len(right) +} diff --git a/pkg/etkedit/edit.go b/pkg/etkedit/edit.go index 3236cd78a..7495f311e 100644 --- a/pkg/etkedit/edit.go +++ b/pkg/etkedit/edit.go @@ -21,6 +21,8 @@ type Editor struct { mutex sync.RWMutex + etkCtx *etk.Context + afterCommand vals.List beforeReadline vals.List afterReadline vals.List @@ -71,6 +73,7 @@ func (ed *Editor) Ns() *eval.Ns { "binding-table": makeBindingMap, "key": toKey, }). + AddGoFns(ed.codeBufferBuiltins()). AddVars(map[string]vars.Var{ "after-command": makeEditVar(ed, &ed.afterCommand), "before-readline": makeEditVar(ed, &ed.beforeReadline), @@ -122,7 +125,19 @@ func (ed *Editor) Comp() etk.Comp { if reaction == etk.Unused { switch ev { case term.K('D', ui.Ctrl): + // TODO: Move this to binding map too return etk.FinishEOF + default: + if keyEvent, ok := ev.(term.KeyEvent); ok { + key := ui.Key(keyEvent) + bm := getField(ed, &ed.insertBinding) + if bm.HasKey(key) { + fn := bm.GetKey(key) + // TODO: Not sure if correct way to call + ed.ev.Call(fn, eval.CallCfg{}, eval.EvalCfg{}) + return etk.Consumed + } + } } } return reaction @@ -149,7 +164,11 @@ func (ed *Editor) ReadCode(tty cli.TTY) (string, error) { tty.ResetBuffer() // TODO: This was easy to miss m, err := etk.Run(ed.Comp(), etk.RunCfg{ TTY: tty, Frame: ed.ev.CallFrame("edit"), MaxHeight: ed.maxHeight, + ContextFn: func(c etk.Context) { + setField(ed, &ed.etkCtx, &c) + }, }) + setField(ed, &ed.etkCtx, nil) if err != nil { return "", err } @@ -177,6 +196,12 @@ func getField[T any](ed *Editor, fieldPtr *T) T { return *fieldPtr } +func setField[T any](ed *Editor, fieldPtr *T, value T) { + ed.mutex.Lock() + defer ed.mutex.Unlock() + *fieldPtr = value +} + // Creates an editVar. This has to be a function because methods can't be // polymorphic. func makeEditVar[F any](ed *Editor, ptr *F) editVar[F] { diff --git a/pkg/etkedit/old_api.go b/pkg/etkedit/old_api.go new file mode 100644 index 000000000..0cb19ef76 --- /dev/null +++ b/pkg/etkedit/old_api.go @@ -0,0 +1,48 @@ +package edit + +import ( + "errors" + + "src.elv.sh/pkg/etk" + "src.elv.sh/pkg/etk/comps" +) + +var errEditorNotActive = errors.New("editor is not active") + +func (ed *Editor) wrapCtxFn(f func(etk.Context) error) func() error { + return func() error { + ctxPtr := getField(ed, &ed.etkCtx) + if ctxPtr == nil { + return errEditorNotActive + } + return f(*ctxPtr) + } +} + +func (ed *Editor) codeBufferBuiltins() map[string]any { + m := make(map[string]any, len(bufferBuiltinsData)) + for name, fn := range bufferBuiltinsData { + m[name] = ed.wrapCtxFn(func(c etk.Context) error { + bufferVar := etk.BindState(c, "code/buffer", comps.TextBuffer{}) + bufferVar.Swap(func(buf comps.TextBuffer) comps.TextBuffer { + fn(&buf) + return buf + }) + return nil + }) + } + return m +} + +/* +func (ed *Editor) wrapBufferFn(f func(comps.TextBuffer) comps.TextBuffer) func() error { + return ed.wrapCtxFn(wrapBufferFn(f)) +} + +func wrapBufferFn(f func(comps.TextBuffer) comps.TextBuffer) func(etk.Context) error { + return func(c etk.Context) error { + etk.BindState(c, "code/buffer", comps.TextBuffer{}).Swap(f) + return nil + } +} +*/