aboutsummaryrefslogblamecommitdiffstats
path: root/lib/ui/textinput.go
blob: 70dcb3f5cce49f4e04aa53ad46c8d7f7663064ca (plain) (tree)
1
2
3
4
5
6
7
8
9


          
              
                 

              
                                     
                                       
 
                                       
                                        

 
                                 



                       








                                               
                                               
                                                           
                                  
                                


                                       
                                          




                                                                            
                                                                
                          



                                            


         




                                                         




                                                       
                                 

                                                                       
                                    
                            


                 



                                      
                                          

                                       
         






                                           
                                                   
                               
                               
                 

 




                                         


                           

                                 
         
                             


                                                                    
 
                                
                                   
                        
                                                                    
                                                            
                                                           
                
                                                                               
         
                                                                         
                     
                                       
                                   


         

















                                                  
                                                                   

                                       
                                      





                                             
                                                                            

                                                       








                                                                                                              
                                        


                                



                                                                          

                                           


         



                                     


                                            
         

                                    


         


                                      
                                                                                                                          
                  
                         
                       
                     


                                   
                                               

                      
                            
                         
                                         

                   







                                                                             



                                                              
                         
                       
                     

 






















                                                          


                                                                             
                                 
                               
                             






                                                                             
                                 
                               
                             


         
                                          
                                    
                                                                                       






                                              
                                 
                              




                                          





                                                






                                                                                
                                                    








                                                            



                                  
                                                                   
                             


                       



                                                             



                                                                   
                                                    
                                                     

                                                             
                                                  

                                                     
                                                  

                                                   
                                                  

                                          
                                                 


                                                    
                                                  

                                                    
                                                 


                                                   
                                                  
                                    
                                         

                                                  
                                                  
                                               
                                         
                                       


                                                  
                                    
                                                  
                                       


                                                  



                                                          
                         

                                            
                                   
                                                  




                                               







                               
                                   













                                             



                                                                           










































                                                                              
                        






                              
                     





                                                 
                                             

                                  
                                                              
















                                                                       



                                           





                                      
                            





















                                                  
                                 








                                                       
package ui

import (
	"math"
	"strings"
	"time"

	"github.com/gdamore/tcell/v2"
	"github.com/mattn/go-runewidth"

	"git.sr.ht/~rjarry/aerc/config"
	"git.sr.ht/~rjarry/aerc/logging"
)

// TODO: Attach history providers
// TODO: scrolling

type TextInput struct {
	Invalidatable
	cells             int
	ctx               *Context
	focus             bool
	index             int
	password          bool
	prompt            string
	scroll            int
	text              []rune
	change            []func(ti *TextInput)
	focusLost         []func(ti *TextInput)
	tabcomplete       func(s string) ([]string, string)
	completions       []string
	prefix            string
	completeIndex     int
	completeDelay     time.Duration
	completeDebouncer *time.Timer
	uiConfig          *config.UIConfig
}

// Creates a new TextInput. TextInputs will render a "textbox" in the entire
// context they're given, and process keypresses to build a string from user
// input.
func NewTextInput(text string, ui *config.UIConfig) *TextInput {
	return &TextInput{
		cells:    -1,
		text:     []rune(text),
		index:    len([]rune(text)),
		uiConfig: ui,
	}
}

func (ti *TextInput) Password(password bool) *TextInput {
	ti.password = password
	return ti
}

func (ti *TextInput) Prompt(prompt string) *TextInput {
	ti.prompt = prompt
	return ti
}

func (ti *TextInput) TabComplete(
	tabcomplete func(s string) ([]string, string), d time.Duration,
) *TextInput {
	ti.tabcomplete = tabcomplete
	ti.completeDelay = d
	return ti
}

func (ti *TextInput) String() string {
	return string(ti.text)
}

func (ti *TextInput) StringLeft() string {
	for ti.index > len(ti.text) {
		ti.index = len(ti.text)
	}
	return string(ti.text[:ti.index])
}

func (ti *TextInput) StringRight() string {
	return string(ti.text[ti.index:])
}

func (ti *TextInput) Set(value string) *TextInput {
	ti.text = []rune(value)
	ti.index = len(ti.text)
	return ti
}

func (ti *TextInput) Invalidate() {
	ti.DoInvalidate(ti)
}

func (ti *TextInput) Draw(ctx *Context) {
	scroll := ti.scroll
	if !ti.focus {
		scroll = 0
	} else {
		ti.ensureScroll()
	}
	ti.ctx = ctx // gross

	defaultStyle := ti.uiConfig.GetStyle(config.STYLE_DEFAULT)
	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', defaultStyle)

	text := ti.text[scroll:]
	sindex := ti.index - scroll
	if ti.password {
		x := ctx.Printf(0, 0, defaultStyle, "%s", ti.prompt)
		cells := runewidth.StringWidth(string(text))
		ctx.Fill(x, 0, cells, 1, '*', defaultStyle)
	} else {
		ctx.Printf(0, 0, defaultStyle, "%s%s", ti.prompt, string(text))
	}
	cells := runewidth.StringWidth(string(text[:sindex]) + ti.prompt)
	if ti.focus {
		ctx.SetCursor(cells, 0)
		ti.drawPopover(ctx)
	}
}

func (ti *TextInput) drawPopover(ctx *Context) {
	if len(ti.completions) == 0 {
		return
	}
	cmp := &completions{
		options:    ti.completions,
		idx:        ti.completeIndex,
		stringLeft: ti.StringLeft(),
		onSelect: func(idx int) {
			ti.completeIndex = idx
			ti.Invalidate()
		},
		onExec: func() {
			ti.executeCompletion()
			ti.invalidateCompletions()
			ti.Invalidate()
		},
		onStem: func(stem string) {
			ti.Set(ti.prefix + stem + ti.StringRight())
			ti.Invalidate()
		},
		uiConfig: ti.uiConfig,
	}
	width := maxLen(ti.completions) + 3
	height := len(ti.completions)
	ctx.Popover(0, 0, width, height, cmp)
}

func (ti *TextInput) MouseEvent(localX int, localY int, event tcell.Event) {
	if event, ok := event.(*tcell.EventMouse); ok {
		if event.Buttons() == tcell.Button1 {
			if localX >= len(ti.prompt)+1 && localX <= len(ti.text[ti.scroll:])+len(ti.prompt)+1 {
				ti.index = localX - len(ti.prompt) - 1
				ti.ensureScroll()
				ti.Invalidate()
			}
		}
	}
}

func (ti *TextInput) Focus(focus bool) {
	if ti.focus && !focus {
		ti.onFocusLost()
	}
	ti.focus = focus
	if focus && ti.ctx != nil {
		cells := runewidth.StringWidth(string(ti.text[:ti.index]))
		ti.ctx.SetCursor(cells+1, 0)
	} else if !focus && ti.ctx != nil {
		ti.ctx.HideCursor()
	}
}

func (ti *TextInput) ensureScroll() {
	if ti.ctx == nil {
		return
	}
	w := ti.ctx.Width() - len(ti.prompt)
	if ti.index >= ti.scroll+w {
		ti.scroll = ti.index - w + 1
	}
	if ti.index < ti.scroll {
		ti.scroll = ti.index
	}
}

func (ti *TextInput) insert(ch rune) {
	left := ti.text[:ti.index]
	right := ti.text[ti.index:]
	ti.text = append(left, append([]rune{ch}, right...)...) //nolint:gocritic // intentional append to different slice
	ti.index++
	ti.ensureScroll()
	ti.Invalidate()
	ti.onChange()
}

func (ti *TextInput) deleteWord() {
	if len(ti.text) == 0 || ti.index <= 0 {
		return
	}
	separators := "/'\""
	i := ti.index - 1
	for i >= 0 && ti.text[i] == ' ' {
		i--
	}
	if strings.ContainsRune(separators, ti.text[i]) {
		for i >= 0 && strings.ContainsRune(separators, ti.text[i]) {
			i--
		}
	} else {
		separators += " "
		for i >= 0 && !strings.ContainsRune(separators, ti.text[i]) {
			i--
		}
	}
	ti.text = append(ti.text[:i+1], ti.text[ti.index:]...)
	ti.index = i + 1
	ti.ensureScroll()
	ti.Invalidate()
	ti.onChange()
}

func (ti *TextInput) deleteLineForward() {
	if len(ti.text) == 0 || len(ti.text) == ti.index {
		return
	}

	ti.text = ti.text[:ti.index]
	ti.ensureScroll()
	ti.Invalidate()
	ti.onChange()
}

func (ti *TextInput) deleteLineBackward() {
	if len(ti.text) == 0 || ti.index == 0 {
		return
	}

	ti.text = ti.text[ti.index:]
	ti.index = 0
	ti.ensureScroll()
	ti.Invalidate()
	ti.onChange()
}

func (ti *TextInput) deleteChar() {
	if len(ti.text) > 0 && ti.index != len(ti.text) {
		ti.text = append(ti.text[:ti.index], ti.text[ti.index+1:]...)
		ti.ensureScroll()
		ti.Invalidate()
		ti.onChange()
	}
}

func (ti *TextInput) backspace() {
	if len(ti.text) > 0 && ti.index != 0 {
		ti.text = append(ti.text[:ti.index-1], ti.text[ti.index:]...)
		ti.index--
		ti.ensureScroll()
		ti.Invalidate()
		ti.onChange()
	}
}

func (ti *TextInput) executeCompletion() {
	if len(ti.completions) > 0 {
		ti.Set(ti.prefix + ti.completions[ti.completeIndex] + ti.StringRight())
	}
}

func (ti *TextInput) invalidateCompletions() {
	ti.completions = nil
}

func (ti *TextInput) onChange() {
	ti.updateCompletions()
	for _, change := range ti.change {
		change(ti)
	}
}

func (ti *TextInput) onFocusLost() {
	for _, focusLost := range ti.focusLost {
		focusLost(ti)
	}
}

func (ti *TextInput) updateCompletions() {
	if ti.tabcomplete == nil {
		// no completer
		return
	}
	if ti.completeDebouncer == nil {
		ti.completeDebouncer = time.AfterFunc(ti.completeDelay, func() {
			defer logging.PanicHandler()
			ti.showCompletions()
		})
	} else {
		ti.completeDebouncer.Stop()
		ti.completeDebouncer.Reset(ti.completeDelay)
	}
}

func (ti *TextInput) showCompletions() {
	if ti.tabcomplete == nil {
		// no completer
		return
	}
	ti.completions, ti.prefix = ti.tabcomplete(ti.StringLeft())
	ti.completeIndex = -1
	ti.Invalidate()
}

func (ti *TextInput) OnChange(onChange func(ti *TextInput)) {
	ti.change = append(ti.change, onChange)
}

func (ti *TextInput) OnFocusLost(onFocusLost func(ti *TextInput)) {
	ti.focusLost = append(ti.focusLost, onFocusLost)
}

func (ti *TextInput) Event(event tcell.Event) bool {
	if event, ok := event.(*tcell.EventKey); ok {
		switch event.Key() {
		case tcell.KeyBackspace, tcell.KeyBackspace2:
			ti.invalidateCompletions()
			ti.backspace()
		case tcell.KeyCtrlD, tcell.KeyDelete:
			ti.invalidateCompletions()
			ti.deleteChar()
		case tcell.KeyCtrlB, tcell.KeyLeft:
			ti.invalidateCompletions()
			if ti.index > 0 {
				ti.index--
				ti.ensureScroll()
				ti.Invalidate()
			}
		case tcell.KeyCtrlF, tcell.KeyRight:
			ti.invalidateCompletions()
			if ti.index < len(ti.text) {
				ti.index++
				ti.ensureScroll()
				ti.Invalidate()
			}
		case tcell.KeyCtrlA, tcell.KeyHome:
			ti.invalidateCompletions()
			ti.index = 0
			ti.ensureScroll()
			ti.Invalidate()
		case tcell.KeyCtrlE, tcell.KeyEnd:
			ti.invalidateCompletions()
			ti.index = len(ti.text)
			ti.ensureScroll()
			ti.Invalidate()
		case tcell.KeyCtrlK:
			ti.invalidateCompletions()
			ti.deleteLineForward()
		case tcell.KeyCtrlW:
			ti.invalidateCompletions()
			ti.deleteWord()
		case tcell.KeyCtrlU:
			ti.invalidateCompletions()
			ti.deleteLineBackward()
		case tcell.KeyESC:
			if ti.completions != nil {
				ti.invalidateCompletions()
				ti.Invalidate()
			}
		case tcell.KeyTab:
			ti.showCompletions()
		case tcell.KeyRune:
			ti.invalidateCompletions()
			ti.insert(event.Rune())
		}
	}
	return true
}

type completions struct {
	options    []string
	stringLeft string
	idx        int
	onSelect   func(int)
	onExec     func()
	onStem     func(string)
	uiConfig   *config.UIConfig
}

func maxLen(ss []string) int {
	max := 0
	for _, s := range ss {
		l := runewidth.StringWidth(s)
		if l > max {
			max = l
		}
	}
	return max
}

func (c *completions) Draw(ctx *Context) {
	bg := c.uiConfig.GetStyle(config.STYLE_COMPLETION_DEFAULT)
	gutter := c.uiConfig.GetStyle(config.STYLE_COMPLETION_GUTTER)
	pill := c.uiConfig.GetStyle(config.STYLE_COMPLETION_PILL)
	sel := c.uiConfig.GetStyleSelected(config.STYLE_COMPLETION_DEFAULT)

	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', bg)

	numVisible := ctx.Height()
	startIdx := 0
	if len(c.options) > numVisible && c.idx+1 > numVisible {
		startIdx = c.idx - (numVisible - 1)
	}
	endIdx := startIdx + numVisible - 1

	for idx, opt := range c.options {
		if idx < startIdx {
			continue
		}
		if idx > endIdx {
			continue
		}
		if c.idx == idx {
			ctx.Fill(0, idx-startIdx, ctx.Width(), 1, ' ', sel)
			ctx.Printf(0, idx-startIdx, sel, " %s ", opt)
		} else {
			ctx.Printf(0, idx-startIdx, bg, " %s ", opt)
		}
	}

	percentVisible := float64(numVisible) / float64(len(c.options))
	if percentVisible >= 1.0 {
		return
	}

	// gutter
	ctx.Fill(ctx.Width()-1, 0, 1, ctx.Height(), ' ', gutter)

	pillSize := int(math.Ceil(float64(ctx.Height()) * percentVisible))
	percentScrolled := float64(startIdx) / float64(len(c.options))
	pillOffset := int(math.Floor(float64(ctx.Height()) * percentScrolled))
	ctx.Fill(ctx.Width()-1, pillOffset, 1, pillSize, ' ', pill)
}

func (c *completions) next() {
	idx := c.idx
	idx++
	if idx > len(c.options)-1 {
		idx = -1
	}
	c.onSelect(idx)
}

func (c *completions) prev() {
	idx := c.idx
	idx--
	if idx < -1 {
		idx = len(c.options) - 1
	}
	c.onSelect(idx)
}

func (c *completions) Event(e tcell.Event) bool {
	if e, ok := e.(*tcell.EventKey); ok {
		switch e.Key() {
		case tcell.KeyTab:
			if len(c.options) == 1 && c.idx >= 0 {
				c.onExec()
			} else {
				stem := findStem(c.options)
				if stem != "" && stem != c.stringLeft {
					c.onStem(stem)
				} else {
					c.next()
				}
			}
			return true
		case tcell.KeyCtrlN, tcell.KeyDown:
			c.next()
			return true
		case tcell.KeyBacktab, tcell.KeyCtrlP, tcell.KeyUp:
			c.prev()
			return true
		case tcell.KeyEnter:
			if c.idx >= 0 {
				c.onExec()
				return true
			}
		}
	}
	return false
}

func findStem(words []string) string {
	if len(words) == 0 {
		return ""
	}
	if len(words) == 1 {
		return words[0]
	}
	var stem string
	stemLen := 1
	firstWord := []rune(words[0])
	for {
		if len(firstWord) < stemLen {
			return stem
		}
		var r rune = firstWord[stemLen-1]
		for _, word := range words[1:] {
			runes := []rune(word)
			if len(runes) < stemLen {
				return stem
			}
			if runes[stemLen-1] != r {
				return stem
			}
		}
		stem += string(r)
		stemLen++
	}
}

func (c *completions) Focus(_ bool) {}

func (c *completions) Invalidate() {}

func (c *completions) OnInvalidate(_ func(Drawable)) {}