summaryrefslogblamecommitdiff
path: root/ui/editor.go
blob: a63438d1d9a17bd0f35aa986c89b9ada14ec2fba (plain) (tree)
1
2
3
4
5
6
7
8


          

                 
                                     

 




                        

                                                                        
                                                                                    

                     




                                                                                 
                   















                                                                               



                                                                  


                                                  



                                                                         

 


                                                                                   
                      
                                           
                                         
                                       
                                           


         
                                    


                               
                                 
                                 



                       


                                       

 
                                
                                     

 
                                  
                         














                                                                                    
                                                         



                                                     

 
                                  


                                                                                
                            
 
                          




                                                             

 
                                      



                            
                                    
                
                         
                         
                                                                      





                                                                                              

              
 
                                             
                                                 



                                
                         
                         


              
                                     
                                   

                                                     

                                    
                                                      

                                                      

                                                                        

                            

 











                                                                
                                                           
                                            
                        






                                                  
                        


                         
                         


              










                                                                                
                                                 

                                                
         
                                   

                                     

                         
                         

                                            

 
                               
                             
                            

                                    
                            

                                     
                       
                         
                   

 


                                   
                            









                                                                            
                          

                         
                         

 
                          
                                                  


                      
                                                                         
                                 
                                                 





                                         






                                     
                                                                 






                                                                       
                         




                         









                                       








                                 
                                                           
                        

                                                                    
                        

         
                         
                         

 
                         


                             

                       
                         
                         

 
                        


                                                  
                                            


                                                                            
                         
                         

 
                       






                            
                         
                         


               
                         
                                       






                            
                         
                         


               
                                                     

                                                                            




                                          

                                                                                                


                                                            
                            

                                                           





                                                                            
 
                         


                   
                               


                                                                                        

                                         

























                                                                                                     
                                     







                                                     






                                              
                                                       

                                
               

                        
                                                          
                                         




                                                                                                 
                                 


                   
                            



                                                     

                                                                           
 









                                                                                      
                          






                                
package ui

import (
	"strings"

	"github.com/gdamore/tcell/v2"
)

type Completion struct {
	Text      []rune
	CursorIdx int
}

// Editor is the text field where the user writes messages and commands.
type Editor struct {
	// text contains the written runes. An empty slice means no text is written.
	text [][]rune

	// history contains the original content of each previously sent message.
	// It gets out of sync with text when history is modified à la readline,
	// then overwrites text when a new message is added.
	history [][]rune

	lineIdx int

	// textWidth[i] contains the width of string(text[:i]). Therefore
	// len(textWidth) is always strictly greater than 0 and textWidth[0] is
	// always 0.
	textWidth []int

	// cursorIdx is the index in text of the placement of the cursor, or is
	// equal to len(text) if the cursor is at the end.
	cursorIdx int

	// offsetIdx is the number of elements of text that are skipped when
	// rendering.
	offsetIdx int

	// width is the width of the screen.
	width int

	autoComplete func(cursorIdx int, text []rune) []Completion
	autoCache    []Completion
	autoCacheIdx int

	backsearch        bool
	backsearchPattern []rune // pre-lowercased

	// oldest (lowest) index in text of lines that were changed.
	// used as an optimization to reduce copying when flushing lines.
	oldestTextChange int
}

// NewEditor returns a new Editor.
// Call Resize() once before using it.
func NewEditor(autoComplete func(cursorIdx int, text []rune) []Completion) Editor {
	return Editor{
		text:         [][]rune{{}},
		history:      [][]rune{},
		textWidth:    []int{0},
		autoComplete: autoComplete,
	}
}

func (e *Editor) Resize(width int) {
	if width < e.width {
		e.cursorIdx = 0
		e.offsetIdx = 0
		e.autoCache = nil
		e.backsearchEnd()
	}
	e.width = width
}

// Content result must not be modified.
func (e *Editor) Content() []rune {
	return e.text[e.lineIdx]
}

func (e *Editor) TextLen() int {
	return len(e.text[e.lineIdx])
}

func (e *Editor) PutRune(r rune) {
	e.autoCache = nil
	lowerRune := runeToLower(r)
	if e.backsearch && e.cursorIdx < e.TextLen() {
		lowerNext := runeToLower(e.text[e.lineIdx][e.cursorIdx])
		if lowerRune == lowerNext {
			e.right()
			e.backsearchPattern = append(e.backsearchPattern, lowerRune)
			return
		}
	}
	e.putRune(r)
	e.right()
	if e.backsearch {
		wasEmpty := len(e.backsearchPattern) == 0
		e.backsearchPattern = append(e.backsearchPattern, lowerRune)
		if wasEmpty {
			e.backsearchUpdate(e.lineIdx - 1)
		} else {
			e.backsearchUpdate(e.lineIdx)
		}
	}
}

func (e *Editor) putRune(r rune) {
	e.text[e.lineIdx] = append(e.text[e.lineIdx], ' ')
	copy(e.text[e.lineIdx][e.cursorIdx+1:], e.text[e.lineIdx][e.cursorIdx:])
	e.text[e.lineIdx][e.cursorIdx] = r
	e.bumpOldestChange()

	rw := runeWidth(r)
	tw := e.textWidth[len(e.textWidth)-1]
	e.textWidth = append(e.textWidth, tw+rw)
	for i := e.cursorIdx + 1; i < len(e.textWidth); i++ {
		e.textWidth[i] = rw + e.textWidth[i-1]
	}
}

func (e *Editor) RemRune() (ok bool) {
	ok = 0 < e.cursorIdx
	if !ok {
		return
	}
	e.remRuneAt(e.cursorIdx - 1)
	e.left()
	e.autoCache = nil
	if e.backsearch {
		if e.TextLen() == 0 || len(e.backsearchPattern) == 0 {
			e.backsearchEnd()
		} else {
			e.backsearchPattern = e.backsearchPattern[:len(e.backsearchPattern)-1]
			e.backsearchUpdate(e.lineIdx)
		}
	}
	return
}

func (e *Editor) RemRuneForward() (ok bool) {
	ok = e.cursorIdx < len(e.text[e.lineIdx])
	if !ok {
		return
	}
	e.remRuneAt(e.cursorIdx)
	e.autoCache = nil
	e.backsearchEnd()
	return
}

func (e *Editor) remRuneAt(idx int) {
	// TODO avoid looping twice
	rw := e.textWidth[idx+1] - e.textWidth[idx]
	for i := idx + 1; i < len(e.textWidth); i++ {
		e.textWidth[i] -= rw
	}
	copy(e.textWidth[idx+1:], e.textWidth[idx+2:])
	e.textWidth = e.textWidth[:len(e.textWidth)-1]

	copy(e.text[e.lineIdx][idx:], e.text[e.lineIdx][idx+1:])
	e.text[e.lineIdx] = e.text[e.lineIdx][:len(e.text[e.lineIdx])-1]

	e.bumpOldestChange()
}

func (e *Editor) RemWord() (ok bool) {
	ok = 0 < e.cursorIdx
	if !ok {
		return
	}

	line := e.text[e.lineIdx]

	// To allow doing something like this (| is the cursor):
	// Hello world|
	// Hello |
	// |
	for e.cursorIdx > 0 && line[e.cursorIdx-1] == ' ' {
		e.remRuneAt(e.cursorIdx - 1)
		e.left()
	}

	for i := e.cursorIdx - 1; i >= 0; i -= 1 {
		if line[i] == ' ' {
			break
		}
		e.remRuneAt(i)
		e.left()
	}

	e.autoCache = nil
	e.backsearchEnd()
	return
}

func (e *Editor) Flush() string {
	content := string(e.text[e.lineIdx])
	if len(content) > 0 {
		// intended []rune -> string -> []rune conversion to make copies
		e.history = append(e.history, []rune(content))
	}
	for i, line := range e.history[e.oldestTextChange:] {
		i := i + e.oldestTextChange
		e.text[i] = append(e.text[i][:0], line...)
	}
	if len(content) > 0 {
		e.text = append(e.text, []rune{})
	} else {
		e.text[len(e.text)-1] = []rune{}
	}
	e.lineIdx = len(e.text) - 1
	e.textWidth = e.textWidth[:1]
	e.cursorIdx = 0
	e.offsetIdx = 0
	e.autoCache = nil
	e.backsearchEnd()
	e.oldestTextChange = len(e.text) - 1
	return content
}

func (e *Editor) Clear() bool {
	if e.TextLen() == 0 {
		return false
	}
	e.text[e.lineIdx] = []rune{}
	e.bumpOldestChange()
	e.textWidth = e.textWidth[:1]
	e.cursorIdx = 0
	e.offsetIdx = 0
	e.autoCache = nil
	return true
}

func (e *Editor) Set(text string) {
	r := []rune(text)
	e.text[e.lineIdx] = r
	e.bumpOldestChange()
	e.cursorIdx = len(r)
	e.computeTextWidth()
	e.offsetIdx = 0
	for e.width < e.textWidth[e.cursorIdx]-e.textWidth[e.offsetIdx]+16 {
		e.offsetIdx++
	}
	e.autoCache = nil
	e.backsearchEnd()
}

func (e *Editor) Right() {
	e.right()
	e.autoCache = nil
	e.backsearchEnd()
}

func (e *Editor) right() {
	if e.cursorIdx == len(e.text[e.lineIdx]) {
		return
	}
	e.cursorIdx++
	if e.width <= e.textWidth[e.cursorIdx]-e.textWidth[e.offsetIdx] {
		e.offsetIdx += 16
		max := len(e.text[e.lineIdx]) - 1
		if max < e.offsetIdx {
			e.offsetIdx = max
		}
	}
}

func (e *Editor) RightWord() {
	line := e.text[e.lineIdx]

	if e.cursorIdx == len(line) {
		return
	}

	for e.cursorIdx < len(line) && line[e.cursorIdx] == ' ' {
		e.Right()
	}
	for i := e.cursorIdx; i < len(line) && line[i] != ' '; i += 1 {
		e.Right()
	}
}

func (e *Editor) Left() {
	e.left()
	e.backsearchEnd()
}

func (e *Editor) left() {
	if e.cursorIdx == 0 {
		return
	}
	e.cursorIdx--
	if e.cursorIdx <= e.offsetIdx {
		e.offsetIdx -= 16
		if e.offsetIdx < 0 {
			e.offsetIdx = 0
		}
	}
}

func (e *Editor) LeftWord() {
	if e.cursorIdx == 0 {
		return
	}

	line := e.text[e.lineIdx]

	for e.cursorIdx > 0 && line[e.cursorIdx-1] == ' ' {
		e.left()
	}
	for i := e.cursorIdx - 1; i >= 0 && line[i] != ' '; i -= 1 {
		e.left()
	}

	e.autoCache = nil
	e.backsearchEnd()
}

func (e *Editor) Home() {
	if e.cursorIdx == 0 {
		return
	}
	e.cursorIdx = 0
	e.offsetIdx = 0
	e.autoCache = nil
	e.backsearchEnd()
}

func (e *Editor) End() {
	if e.cursorIdx == len(e.text[e.lineIdx]) {
		return
	}
	e.cursorIdx = len(e.text[e.lineIdx])
	for e.width < e.textWidth[e.cursorIdx]-e.textWidth[e.offsetIdx]+16 {
		e.offsetIdx++
	}
	e.autoCache = nil
	e.backsearchEnd()
}

func (e *Editor) Up() {
	if e.lineIdx == 0 {
		return
	}
	e.lineIdx--
	e.computeTextWidth()
	e.cursorIdx = 0
	e.offsetIdx = 0
	e.autoCache = nil
	e.backsearchEnd()
	e.End()
}

func (e *Editor) Down() {
	if e.lineIdx == len(e.text)-1 {
		e.Flush()
		return
	}
	e.lineIdx++
	e.computeTextWidth()
	e.cursorIdx = 0
	e.offsetIdx = 0
	e.autoCache = nil
	e.backsearchEnd()
	e.End()
}

func (e *Editor) AutoComplete(offset int) (ok bool) {
	if e.autoCache == nil {
		e.autoCache = e.autoComplete(e.cursorIdx, e.text[e.lineIdx])
		if len(e.autoCache) == 0 {
			e.autoCache = nil
			return false
		}
		e.autoCacheIdx = 0
	} else {
		e.autoCacheIdx = (e.autoCacheIdx + len(e.autoCache) + offset) % len(e.autoCache)
	}

	e.text[e.lineIdx] = e.autoCache[e.autoCacheIdx].Text
	e.bumpOldestChange()
	e.cursorIdx = e.autoCache[e.autoCacheIdx].CursorIdx
	e.computeTextWidth()
	if len(e.textWidth) <= e.offsetIdx {
		e.offsetIdx = 0
	}
	for e.width < e.textWidth[e.cursorIdx]-e.textWidth[e.offsetIdx]+16 {
		e.offsetIdx++
	}

	e.backsearchEnd()
	return true
}

func (e *Editor) BackSearch() {
	if !e.backsearch {
		e.backsearch = true
		e.backsearchPattern = []rune(strings.ToLower(string(e.text[e.lineIdx])))
	}
	e.backsearchUpdate(e.lineIdx - 1)
}

func (e *Editor) backsearchUpdate(start int) {
	if len(e.backsearchPattern) == 0 {
		return
	}
	pattern := string(e.backsearchPattern)
	for i := start; i >= 0; i-- {
		if match := strings.Index(strings.ToLower(string(e.text[i])), pattern); match >= 0 {
			e.lineIdx = i
			e.computeTextWidth()
			e.cursorIdx = runeOffset(string(e.text[i]), match) + len(e.backsearchPattern)
			e.offsetIdx = 0
			for e.width < e.textWidth[e.cursorIdx]-e.textWidth[e.offsetIdx]+16 {
				e.offsetIdx++
			}
			e.autoCache = nil
			break
		}
	}
}

func (e *Editor) backsearchEnd() {
	e.backsearch = false
}

func (e *Editor) computeTextWidth() {
	e.textWidth = e.textWidth[:1]
	rw := 0
	for _, r := range e.text[e.lineIdx] {
		rw += runeWidth(r)
		e.textWidth = append(e.textWidth, rw)
	}
}

// call this everytime e.text is modified
func (e *Editor) bumpOldestChange() {
	if e.oldestTextChange > e.lineIdx {
		e.oldestTextChange = e.lineIdx
	}
}

func (e *Editor) Draw(screen tcell.Screen, x0, y int) {
	st := tcell.StyleDefault

	x := x0
	i := e.offsetIdx

	for i < len(e.text[e.lineIdx]) && x < x0+e.width {
		r := e.text[e.lineIdx][i]
		s := st
		if e.backsearch && i < e.cursorIdx && i >= e.cursorIdx-len(e.backsearchPattern) {
			s = s.Underline(true)
		}
		screen.SetContent(x, y, r, nil, s)
		x += runeWidth(r)
		i++
	}

	for x < x0+e.width {
		screen.SetContent(x, y, ' ', nil, st)
		x++
	}

	cursorX := x0 + e.textWidth[e.cursorIdx] - e.textWidth[e.offsetIdx]
	screen.ShowCursor(cursorX, y)
}

// runeOffset returns the lowercase version of a rune
// TODO: len(strings.ToLower(string(r))) == len(strings.ToUpper(string(r))) for all x?
func runeToLower(r rune) rune {
	return []rune(strings.ToLower(string(r)))[0]
}

// runeOffset returns the rune index of the rune starting at byte i in string s
func runeOffset(s string, pos int) int {
	n := 0
	for i := range s {
		if i >= pos {
			return n
		}
		n++
	}
	return n
}